Compare commits

...

52 Commits

Author SHA1 Message Date
Aiirondev f9b991d991 Changes to fix the Barcode Search 2026-10-27 10:03:00 +00:00
Aiirondev_dev 363f0df530 changes to the student Name Processing 2026-10-20 20:07:23 +02:00
Aiirondev_dev 34bb3cf4c3 changes to the user processing in the api_item_datail Function 2026-10-20 19:20:36 +02:00
Aiirondev_dev 18706c6d1c slight change to get the right information out of a database record 2026-10-20 19:08:58 +02:00
Aiirondev_dev 9771b98075 logging implementatiion for the user displayment 2026-10-20 18:56:59 +02:00
Aiirondev_dev e380ad6092 Implementation of a nother user decryption detail displayment 2026-10-20 18:44:58 +02:00
Aiirondev_dev d6e8e08b10 Debug logging implementation and changes to the User entcryption 2026-10-20 18:32:23 +02:00
Aiirondev_dev ddb6ab82ad change of the last borrowing user displayment to correctly change the encryption to have the unencryptet name 2026-10-20 17:46:51 +02:00
Aiirondev_dev baefc55257 changes to the get_user_by_student_card function because of a faulty update regarding the searching of the student Cars 2026-10-20 17:29:07 +02:00
Aiirondev_dev 6c359089cd Including of logging information 2026-10-20 17:13:29 +02:00
Aiirondev_dev 479cf3d98c changes to the formating 2026-10-20 16:37:31 +02:00
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
Aiirondev_dev c2bfed710d changes to the upload function 2026-09-24 16:46:32 +02:00
Aiirondev_dev 5d90eab154 changes to the edeting filter displaying 2026-09-24 16:40:21 +02:00
Aiirondev_dev 1eadfff9e1 slight changes 2026-09-24 16:33:18 +02:00
Aiirondev_dev 18f096b3f2 changes to the filter change 2026-09-24 16:29:05 +02:00
Aiirondev_dev 4b895a52ab changes to the Liught / Darkmode Toggle to move it to the personal settings 2026-09-23 22:23:42 +02:00
Aiirondev_dev 3a504afe37 Test change for the Darkmode and light mode toggle 2026-09-23 22:13:28 +02:00
Aiirondev_dev fb2ba86140 changes to be implementet to fix the crutial mistake of the variable passing 2026-09-23 21:36:44 +02:00
Aiirondev_dev 72e8a8cf5d fix for the issue implementet 2026-09-23 21:35:34 +02:00
Aiirondev_dev a158d16f62 changes to the passed variables 2026-09-23 21:20:53 +02:00
Aiirondev_dev b8347b80f2 Tryd some changes to the generate_ausweis_id() 2026-09-23 21:17:54 +02:00
Aiirondev_dev 31356e7178 chgange of the Titel Medien / Ausleihe to Ausleihe / Rückgabe 2026-09-23 21:05:52 +02:00
Aiirondev_dev 0409813cbe changes to the style for the Manuell input fields for the Libnrary borrowings. 2026-09-23 17:11:08 +02:00
Aiirondev_dev 95e02b7a75 changes to descriptors 2026-09-23 16:53:59 +02:00
Aiirondev_dev 3b0d499958 changes to implement a filter option for the student cards 2026-09-23 16:25:30 +02:00
Aiirondev_dev a7494648d8 changes to implement an option to view the password beeing typed in 2026-09-23 16:11:12 +02:00
Aiirondev_dev 8d8609fd70 removal of the debug 2026-09-21 15:57:54 +02:00
Aiirondev_dev 18c831eb42 debugging for the next function 2026-09-21 15:39:55 +02:00
Aiirondev_dev 8895283904 debugging for the user generation from the csv import 2026-09-21 15:26:57 +02:00
18 changed files with 1042 additions and 402 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
+328 -227
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}}
@@ -2301,7 +2423,12 @@ def _upload_student_cards_excel():
row_errors.append('Vorname und Nachname fehlen')
if not ausweis_id and student_name:
ausweis_id = generate_ausweis_id(existing_ids)
ausweis_id = generate_ausweis_id()
if ausweis_id in existing_ids:
row_errors.append(f'Automatisch erzeugte Ausweis-ID {ausweis_id} existiert bereits')
else:
while ausweis_id in existing_ids:
ausweis_id = generate_ausweis_id()
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
existing_ids.add(ausweis_id.upper())
elif ausweis_id and not rollover_mode:
@@ -3501,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."""
@@ -3758,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
@@ -3787,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(
@@ -3821,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:
@@ -4360,10 +4349,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,
@@ -4375,7 +4371,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,
}
@@ -4465,6 +4461,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')))
@@ -4501,16 +4498,25 @@ 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'):
@@ -4533,17 +4539,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>"
@@ -4850,7 +4852,8 @@ def student_cards_admin():
if ausweis_id:
existing = student_cards.find_one({'AusweisId': ausweis_id})
else:
ausweis_id = generate_ausweis_id()
generated_ausweis_id = generate_ausweis_id()
ausweis_id = generated_ausweis_id
existing = False
if not ausweis_ident:
ausweis_ident = ausweis_id
@@ -4890,10 +4893,10 @@ def student_cards_admin():
return render_template(
'student_cards_admin.html',
student_cards=all_cards, # Hier all_cards übergeben
student_cards=all_cards,
edit_mode=edit_mode,
form_data=form_data,
available_classes=available_classes, # Enthält nun die lesbaren Klassen
available_classes=available_classes,
config=cfg.get_school_info(),
email_service_enabled=cfg.MODULES.is_enabled('mail')
)
@@ -5599,6 +5602,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
@@ -5729,6 +5733,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()
@@ -5764,6 +5770,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 = {
@@ -5830,6 +5872,7 @@ def get_items():
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'User': 1,
'Name': 1,
}
# Full mode: complete child data with all details
@@ -5838,6 +5881,7 @@ def get_items():
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'User': 1,
'Name': 1,
'Images': 1,
'ThumbnailInfo': 1,
@@ -5865,13 +5909,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,
@@ -5884,6 +5941,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):
@@ -5945,10 +6003,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
@@ -5994,7 +6049,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,
})
@@ -6114,7 +6169,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 = []
@@ -7455,6 +7513,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'
@@ -7528,6 +7591,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))
@@ -7602,7 +7666,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,
@@ -7699,7 +7763,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,
@@ -7750,7 +7814,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,
@@ -7801,7 +7865,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)
@@ -7810,7 +7880,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'
})
@@ -7826,6 +7896,7 @@ def zurueckgeben(id):
{'$set': {
'Status': 'completed',
'End': end_date,
'ReturnPending': False,
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -7911,7 +7982,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)
@@ -8193,7 +8269,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:
@@ -8246,7 +8322,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,
@@ -8313,7 +8389,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,
@@ -8344,8 +8420,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
@@ -8529,7 +8607,7 @@ def parse_csv_users(file_bytes):
return parsed_users
def generate_compliant_password(length=16):
def generate_compliant_password(length=6):
lowers = string.ascii_lowercase
uppers = string.ascii_uppercase
digits = string.digits
@@ -8664,7 +8742,9 @@ def register_csv():
# Benutzernamen & Passwort generieren
username = us.build_unique_username_from_name(name, last_name)
password = generate_compliant_password(5)
password = generate_compliant_password(6)
password = us.check_password_strength(password) and password or generate_compliant_password(6)
action_permissions = None
page_permissions = None
@@ -10905,7 +10985,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:
@@ -11438,6 +11520,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]
@@ -11454,31 +11543,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'])
@@ -11490,14 +11575,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)
@@ -11508,12 +11600,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)'}
@@ -12273,7 +12367,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,
@@ -13767,6 +13861,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):
+1 -1
View File
@@ -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.
+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.
+2 -2
View File
@@ -78,11 +78,11 @@
<div class="form-row">
<div class="form-group">
<label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fachgebiet') }}" placeholder="z. B. Fachgebiet">
</div>
<div class="form-group">
<label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Fach') }}" placeholder="z. B. Fach">
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
+23 -22
View File
@@ -1135,12 +1135,7 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
@@ -1199,6 +1194,13 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
@@ -1228,12 +1230,6 @@
</li>
{% endif %}
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
@@ -1305,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">
@@ -1321,6 +1315,13 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
@@ -1329,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">
@@ -1342,7 +1342,7 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', False) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien/Ausleihe</a>
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Ausleihe/Rückgabe</a>
</li>
{% endif %}
{% if 'username' in session %}
@@ -1359,12 +1359,6 @@
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
@@ -1431,6 +1425,13 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
+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 %}
+4 -4
View File
@@ -278,8 +278,8 @@
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter1-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter1-{{ idx + 1 }}" name="filter" class="filter-dropdown-select" data-selected="{{ item.Filter[idx] if item.Filter and item.Filter|length > idx else '' }}">
<label for="filter2-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter2-{{ idx + 1 }}" name="filter" class="filter-dropdown-select" data-selected="{{ item.Filter[idx] if item.Filter and item.Filter|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
@@ -290,8 +290,8 @@
<div class="multi-filter">
{% for idx in range(4) %}
<div class="form-group">
<label for="filter2-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter2-{{ idx + 1 }}" name="filter2" class="filter-dropdown-select" data-selected="{{ item.Filter2[idx] if item.Filter2 and item.Filter2|length > idx else '' }}">
<label for="filter1-{{ idx + 1 }}">Wert {{ idx + 1 }}:</label>
<select id="filter1-{{ idx + 1 }}" name="filter2" class="filter-dropdown-select" data-selected="{{ item.Filter2[idx] if item.Filter2 and item.Filter2|length > idx else '' }}">
<option value="">-- Optional --</option>
</select>
</div>
+32 -4
View File
@@ -133,6 +133,34 @@
background: var(--ui-surface);
}
/* Blue edge styling for inputs and action button */
#activeStudentCard,
#manualItemCode {
border: 2px solid #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
#activeStudentCard:focus,
#manualItemCode:focus {
outline: none;
border-color: #2563eb !important;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.25) !important;
}
#manualActionBtn {
background: #4f46e5 !important;
color: white !important;
border: 2px solid #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
#manualActionBtn:hover {
background: #4338ca !important;
border-color: #2563eb !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.3);
}
.library-scan-status {
margin-top: 10px;
font-size: 0.92em;
@@ -605,7 +633,6 @@
letter-spacing: 2px;
}
.detail-gallery-container {
display: flex;
flex-wrap: wrap;
@@ -665,18 +692,18 @@
<div class="library-scan-controls">
<select id="scanModeSelect" aria-label="Scan-Modus">
<option value="card_only">Nur Ausweis erfassen</option>
<option value="quick_toggle">Schnellmodus: Ausweis + Mediencode</option>
<option value="quick_toggle">Schnellmodus: 1x Ausweis + 1x Mediencode</option>
<option value="continuous">Dauermodus: 1x Ausweis, dann N Medien</option>
<option value="return_only">Nur Rückgabe (nur Mediencode)</option>
</select>
<input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)">
<input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;">
<button id="resetCardBtn" class="button" type="button">Feld zurücksetzen</button>
<button id="toggleScannerBtn" class="button" type="button">Scanner starten</button>
<button id="toggleScannerBtn" class="button" type="button">Handyscanner starten</button>
<button id="physicalScannerBtn" class="button" type="button" style="margin-left:6px;">
Physischer Scanner starten
</button>
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px;">Code verarbeiten</button>
</div>
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
@@ -1392,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);
}
+268 -57
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; }
@@ -2343,7 +2499,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-container">
<div class="filter-group">
<div class="filter-header">
<label>{{ filter_names.get('2', 'Fach') }}</label>
<label>{{ filter_names.get('1', 'Fach') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
</div>
@@ -2357,7 +2513,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group">
<div class="filter-header">
<label>{{ filter_names.get('1', 'Klassenstufe') }}</label>
<label>{{ filter_names.get('2', 'Klassenstufe') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
</div>
@@ -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,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.
@@ -4112,17 +4323,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
<div class="detail-group">
<div class="detail-label">Unterrichtsfach:</div>
<div class="detail-label">{{ filter_names.get('1', 'Unterrichtsfach') }}:</div>
<div class="detail-value">${escapeHtml(filter1Array.join(', ') || '-')}</div>
</div>
<div class="detail-group">
<div class="detail-label">Jahrgangsstufe:</div>
<div class="detail-label">{{ filter_names.get('2', 'Jahrgangsstufe') }}:</div>
<div class="detail-value">${escapeHtml(filter2Array.join(', ') || '-')}</div>
</div>
<div class="detail-group">
<div class="detail-label">Schlagwort:</div>
<div class="detail-label">{{ filter_names.get('3', 'Schlagwort') }}:</div>
<div class="detail-value">${escapeHtml(filter3Array.join(', ') || '-')}</div>
</div>
+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;
+121 -14
View File
@@ -1,11 +1,3 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
@@ -21,7 +13,6 @@
gap: 15px;
}
/* Neu strukturiertes Layout für Formular & Import */
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
@@ -145,6 +136,38 @@
background: #5a6268;
}
/* Filter Controls Styling */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
align-items: center;
background: var(--ui-bg);
padding: 12px;
border-radius: 8px;
border: 1px solid #ddd;
}
.filter-bar input,
.filter-bar select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.filter-bar input {
flex: 1;
min-width: 200px;
}
.filter-counter {
font-size: 13px;
color: #666;
margin-left: auto;
}
.cards-table {
width: 100%;
border-collapse: collapse;
@@ -255,7 +278,8 @@
}
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .import-card {
:root[data-theme="dark"] .import-card,
:root[data-theme="dark"] .filter-bar {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
@@ -269,7 +293,8 @@
:root[data-theme="dark"] .import-card p,
:root[data-theme="dark"] .rollover-hint,
:root[data-theme="dark"] .empty-state,
:root[data-theme="dark"] .empty-state p {
:root[data-theme="dark"] .empty-state p,
:root[data-theme="dark"] .filter-counter {
color: var(--ui-text-muted) !important;
}
@@ -286,6 +311,8 @@
:root[data-theme="dark"] .form-group input,
:root[data-theme="dark"] .form-group select,
:root[data-theme="dark"] .form-group textarea,
:root[data-theme="dark"] .filter-bar input,
:root[data-theme="dark"] .filter-bar select,
:root[data-theme="dark"] .export-buttons form,
:root[data-theme="dark"] .export-buttons select {
background: var(--ui-bg) !important;
@@ -297,6 +324,8 @@
:root[data-theme="dark"] .form-group input:focus,
:root[data-theme="dark"] .form-group select:focus,
:root[data-theme="dark"] .form-group textarea:focus,
:root[data-theme="dark"] .filter-bar input:focus,
:root[data-theme="dark"] .filter-bar select:focus,
:root[data-theme="dark"] .export-buttons select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22) !important;
@@ -334,6 +363,16 @@
.export-buttons > a {
flex: 1 1 100%;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-counter {
margin-left: 0;
text-align: right;
}
}
@media (max-width: 992px) {
@@ -474,6 +513,8 @@
<h3 style="margin:0 0 8px 0;">Excel-Import</h3>
<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>
@@ -503,7 +544,20 @@
<div>
<h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2>
{% if student_cards %}
<table class="cards-table">
<!-- Filter Options -->
<div class="filter-bar">
<input type="text" id="cardSearchInput" placeholder="🔍 Name oder Ausweis-ID suchen...">
<select id="classFilterSelect">
<option value="">Alle Klassen</option>
{% for cls in available_classes %}
<option value="{{ cls }}">{{ cls }}</option>
{% endfor %}
</select>
<button type="button" id="clearFilterBtn" class="btn-cancel" style="padding: 8px 12px; font-size: 13px;">Zurücksetzen</button>
<span class="filter-counter" id="filterCounter">Angezeigt: {{ student_cards|length }} / {{ student_cards|length }}</span>
</div>
<table class="cards-table" id="cardsTable">
<thead>
<tr>
<th>Ausweis-ID</th>
@@ -516,7 +570,7 @@
</thead>
<tbody>
{% for card in student_cards %}
<tr>
<tr class="card-row" data-id="{{ card.AusweisId|lower }}" data-name="{{ card.SchülerName|lower }}" data-class="{{ card.Klasse or '' }}">
<td><strong>{{ card.AusweisId }}</strong></td>
<td>{{ card.SchülerName }}</td>
<td>{{ card.Klasse or '—' }}</td>
@@ -546,6 +600,10 @@
{% endfor %}
</tbody>
</table>
<div id="noResultsMsg" class="empty-state" style="display: none;">
<p>Keine passende Ausweise für diesen Filter gefunden.</p>
</div>
{% else %}
<div class="empty-state">
<p>Keine Bibliotheksausweise registriert.</p>
@@ -557,7 +615,56 @@
<script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script>
<script>
// All PDF exports now go through backend routes
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('cardSearchInput');
const classSelect = document.getElementById('classFilterSelect');
const clearBtn = document.getElementById('clearFilterBtn');
const filterCounter = document.getElementById('filterCounter');
const rows = document.querySelectorAll('.card-row');
const noResultsMsg = document.getElementById('noResultsMsg');
const totalRows = rows.length;
function filterCards() {
const query = searchInput ? searchInput.value.toLowerCase().trim() : '';
const selectedClass = classSelect ? classSelect.value : '';
let visibleCount = 0;
rows.forEach(row => {
const id = row.getAttribute('data-id') || '';
const name = row.getAttribute('data-name') || '';
const cardClass = row.getAttribute('data-class') || '';
const matchesQuery = !query || id.includes(query) || name.includes(query);
const matchesClass = !selectedClass || cardClass === selectedClass;
if (matchesQuery && matchesClass) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
if (filterCounter) {
filterCounter.textContent = `Angezeigt: ${visibleCount} / ${totalRows}`;
}
if (noResultsMsg) {
noResultsMsg.style.display = (visibleCount === 0 && totalRows > 0) ? 'block' : 'none';
}
}
if (searchInput) searchInput.addEventListener('input', filterCards);
if (classSelect) classSelect.addEventListener('change', filterCards);
if (clearBtn) {
clearBtn.addEventListener('click', function() {
if (searchInput) searchInput.value = '';
if (classSelect) classSelect.value = '';
filterCards();
});
}
});
</script>
{% endblock %}
+34 -34
View File
@@ -738,7 +738,7 @@
{% if show_library_features %}
<div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich. Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
@@ -750,7 +750,7 @@
{% else %}
<div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl). Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
@@ -865,38 +865,6 @@
<!-- Normal Item Mode: Standard Filters -->
<div class="filter-inputs">
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter1-1">Wert 1:</label>
<select id="filter1-1" name="filter" class="filter-dropdown-select" required>
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-2">Wert 2:</label>
<select id="filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-3">Wert 3:</label>
<select id="filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-4">Wert 4:</label>
<select id="filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter2-1">Wert 1:</label>
@@ -928,6 +896,38 @@
</div>
</div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter">
<div class="form-group">
<label for="filter1-1">Wert 1:</label>
<select id="filter1-1" name="filter" class="filter-dropdown-select" required>
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-2">Wert 2:</label>
<select id="filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-3">Wert 3:</label>
<select id="filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="filter1-4">Wert 4:</label>
<select id="filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter">
<div class="form-group">
+30 -3
View File
@@ -228,7 +228,12 @@
</div>
<div class="mb-3">
<label for="new-password" class="form-label">Neues Passwort:</label>
<input type="password" class="form-control" 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" id="togglePasswordBtn" onclick="togglePasswordVisibility()">
Anzeigen
</button>
</div>
<div class="password-requirements small text-muted mt-1">
<p>Das Passwort muss mindestens 6 Zeichen lang sein.</p>
</div>
@@ -397,14 +402,36 @@
modal.show();
}
function togglePasswordVisibility() {
var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleBtn.textContent = 'Verbergen';
} else {
passwordInput.type = 'password';
toggleBtn.textContent = 'Anzeigen';
}
}
function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username;
var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
// Reset password field state on modal open
passwordInput.value = '';
passwordInput.type = 'password';
if (toggleBtn) {
toggleBtn.textContent = 'Anzeigen';
}
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show();
}
function openPermissionsModal(button) {
var username = button.getAttribute('data-username') || '';
var preset = button.getAttribute('data-preset') || 'standard_user';