Compare commits

...

90 Commits

Author SHA1 Message Date
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
Aiirondev_dev a357881a20 changes to the upload of users process. 2026-09-21 15:12:07 +02:00
Aiirondev_dev b151dae3a8 changes to the descriptor 2026-09-21 14:51:15 +02:00
Aiirondev_dev 6167bfca82 changes to the main page 2026-09-21 14:36:03 +02:00
Aiirondev_dev c44a481382 changes to the beschriftungs presets 2026-09-20 13:02:54 +02:00
Aiirondev_dev 904972af53 changes to the item filter describtor 2026-09-20 12:33:02 +02:00
Aiirondev_dev de4098ebb2 Changes to the Timing of the removal of the Small Confirm 2026-09-20 11:42:39 +02:00
Aiirondev_dev 53b33afa80 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-20 11:34:00 +02:00
Aiirondev_dev 0e9c7c6eb2 changes to the Name importing 2026-09-20 11:33:22 +02:00
Aiirondev_dev f442648dc9 changes to the mobile design to make it better fr the mobile view 2026-09-18 22:01:22 +02:00
Aiirondev_dev d2e2339d21 removal of test emial sending service 2026-09-18 16:01:37 +02:00
Aiirondev_dev e42d82fb62 allow to oush to 1.0.0 2026-09-18 15:53:21 +02:00
Aiirondev_dev c5726756d0 implementation of needet clarification 2026-09-17 21:29:24 +02:00
Aiirondev_dev 7d0147061a changes to the Email processing 2026-09-17 21:04:03 +02:00
Aiirondev_dev 76efa85419 changes to the sending test_mail profile 2026-09-17 20:39:10 +02:00
Aiirondev_dev a4cf9626cd changes to the send testmail 2026-09-17 20:30:48 +02:00
Aiirondev_dev fa55717f7f changes to have the test email function 2026-09-17 20:22:30 +02:00
Aiirondev_dev 4943aff944 changes to the email processing 2026-09-17 20:13:26 +02:00
Aiirondev_dev ee1683abe1 fix of faulty spelling 2026-09-17 19:59:35 +02:00
Aiirondev_dev 320acb5256 changes to the email sending 2026-09-17 19:36:48 +02:00
Aiirondev_dev 7b2a6d7bc1 test mail sender 2026-09-17 19:28:24 +02:00
Aiirondev_dev 027cef257d changes to the client building for the email requests 2026-09-17 17:42:00 +02:00
Aiirondev_dev cbacda34ca temporary removal of the tutorial Page 2026-09-17 16:46:31 +02:00
Aiirondev_dev cbc805919a changes to the Email behaivior 2026-09-16 17:45:16 +02:00
Aiirondev 31e8b7ccb4 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:25 +00:00
Aiirondev 5d908b6142 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:12 +00:00
Aiirondev aa9eb4996a revert eba20b7e2e
revert Fixes for the Secret deployment
2026-09-16 15:36:35 +00:00
Aiirondev 43f963d0d3 revert 7ab2899e92
revert changes to the Secret changes
2026-09-16 15:36:10 +00:00
Aiirondev 6589b8d275 revert 8673c0f05c
revert secrets processibg chanbged
2026-09-16 15:35:55 +00:00
Aiirondev 612ce2e6e6 revert d49ec9fd34
revert changes to the env processing
2026-09-16 15:35:36 +00:00
Aiirondev 210483d250 revert 6b3c35f895
revert changes tp implement the env file
2026-09-16 15:35:21 +00:00
Aiirondev 943b48ce16 revert 1dbe5709f5
revert changes to some settings
2026-09-16 15:35:12 +00:00
Aiirondev 476dfee0ff revert d002c5f9da
revert changes
2026-09-16 15:35:04 +00:00
Aiirondev_dev d002c5f9da changes 2026-09-16 17:14:37 +02:00
Aiirondev_dev 1dbe5709f5 changes to some settings 2026-09-16 15:43:17 +02:00
Aiirondev_dev 6b3c35f895 changes tp implement the env file 2026-09-16 15:27:07 +02:00
Aiirondev_dev d49ec9fd34 changes to the env processing 2026-09-16 15:16:31 +02:00
Aiirondev_dev 8673c0f05c secrets processibg chanbged 2026-09-16 15:02:00 +02:00
Aiirondev_dev 7ab2899e92 changes to the Secret changes 2026-09-16 14:36:04 +02:00
Aiirondev_dev eba20b7e2e Fixes for the Secret deployment 2026-09-16 14:16:13 +02:00
25 changed files with 1328 additions and 627 deletions
+6 -10
View File
@@ -150,10 +150,7 @@ jobs:
LATEST_MAJOR="0"
fi
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1
@@ -253,12 +250,11 @@ jobs:
INVENTAR_SECRET_KEY: ${{secrets.INVENTAR_SECRET_KEY}}
INVENTAR_DATA_ENCRYPTION_KEY: ${{secrets.INVENTAR_DATA_ENCRYPTION_KEY}}
INVENTAR_MONGODB_PASSWORD: ${{secrets.INVENTAR_MONGODB_PASSWORD}}
EMAIL_ENABLED: "${{ secrets.EMAIL_ENABLED }}"
EMAIL_SMTP_HOST: "${{ secrets.EMAIL_SMTP_HOST }}"
EMAIL_SMTP_PORT: "${{ secrets.EMAIL_SMTP_PORT }}"
EMAIL_USERNAME: "${{ secrets.EMAIL_USERNAME }}"
EMAIL_PASSWORD: "${{ secrets.EMAIL_PASSWORD }}"
EMAIL_FROM_ADDRESS: "${{ secrets.EMAIL_FROM_ADDRESS }}"
EMAIL_ENABLED: ${{secrets.EMAIL_ENABLED}}
EMAIL_SMTP_HOST: ${{secrets.EMAIL_SMTP_HOST}}
EMAIL_SMTP_PORT: ${{secrets.EMAIL_SMTP_PORT}}
EMAIL_USERNAME: ${{secrets.EMAIL_USERNAME}}
EMAIL_PASSWORD: ${{secrets.EMAIL_PASSWORD}}
expose:
- "8000"
volumes:
+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
+371 -246
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}}
@@ -2224,8 +2346,8 @@ def _upload_student_cards_excel():
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'ausweis_ident': ['lokales differenzierungsmerkmal', 'lokales_differenzierungsmerkmal', 'ausweis_ident',
'differenzierungsmerkmal'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
'last_name': ['nachname', 'last_name', 'lastname'],
'first_name': ['name', 'vorname', 'first_name', 'firstname', 'rufname'],
'last_name': ['nachname', 'last_name', 'lastname', 'familienname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage',
@@ -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:
@@ -3490,6 +3617,8 @@ def library_loans_admin():
damaged_items=damaged_items,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
email_service_enabled=cfg.MODULES.is_enabled('mail')
)
except Exception as e:
app.logger.error(f"Error loading library loans admin view: {e}")
@@ -3499,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."""
@@ -3756,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
@@ -3785,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(
@@ -3819,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:
@@ -4358,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,
@@ -4373,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,
}
@@ -4463,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')))
@@ -4499,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'):
@@ -4531,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>"
@@ -4848,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
@@ -4888,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')
)
@@ -5597,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
@@ -5727,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()
@@ -5762,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 = {
@@ -5828,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
@@ -5836,6 +5881,7 @@ def get_items():
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'User': 1,
'Name': 1,
'Images': 1,
'ThumbnailInfo': 1,
@@ -5863,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,
@@ -5882,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):
@@ -5943,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
@@ -5992,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,
})
@@ -6112,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 = []
@@ -7453,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'
@@ -7526,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))
@@ -7600,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,
@@ -7697,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,
@@ -7748,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,
@@ -7799,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)
@@ -7808,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'
})
@@ -7824,6 +7896,7 @@ def zurueckgeben(id):
{'$set': {
'Status': 'completed',
'End': end_date,
'ReturnPending': False,
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -7909,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)
@@ -8191,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:
@@ -8244,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,
@@ -8311,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,
@@ -8342,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
@@ -8527,20 +8607,18 @@ 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
symbols = "!@#$%^&*()_+~|}{[]:;?><,.-="
# Ensure at least one character from each required category
pwd = [
secrets.choice(lowers),
secrets.choice(uppers),
secrets.choice(digits),
secrets.choice(symbols)
secrets.choice(digits)
]
all_chars = lowers + uppers + digits + symbols
all_chars = lowers + uppers + digits
pwd += [secrets.choice(all_chars) for _ in range(length - 4)]
# Shuffle so guaranteed types aren't always at the start
@@ -8664,19 +8742,31 @@ def register_csv():
# Benutzernamen & Passwort generieren
username = us.build_unique_username_from_name(name, last_name)
password = generate_compliant_password(16)
password = generate_compliant_password(6)
# In DB speichern
success = us.add_user(
username=username,
password=password,
name=name,
last_name=last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
)
password = us.check_password_strength(password) and password or generate_compliant_password(6)
action_permissions = None
page_permissions = None
try:
us.add_user(
username,
password,
name,
last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
)
success = True
except Exception as e:
app.logger.error(f"Fehler beim Erstellen des Benutzers {username}: {e}")
success = False
continue
if success:
created_users.append({
@@ -10377,7 +10467,7 @@ def admin_reset_user_password():
# Reset the password
try:
us.update_password(username, new_password)
flash(f'Passwort für {encrypt_text(username)} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
except Exception as e:
app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}')
flash('Fehler beim Zurücksetzen des Passworts', 'error')
@@ -10895,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:
@@ -11428,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]
@@ -11444,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'])
@@ -11480,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)
@@ -11498,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)'}
@@ -12263,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,
@@ -13755,4 +13859,25 @@ def upload_csv_batch():
"images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count
}), 200
}), 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():
#Test endpoint to send a sample email.
#This is for development purposes only.
try:
send(email="maximiliangruendinger@gmail.com", subject="Test Email from Inventarsystem", note="This is a test email sent from the Inventarsystem application.", sender="Inventarsystem")
return "Test email sent successfully."
except Exception as e:
return f"Failed to send test email: {str(e)}", 500
"""
+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):
+5 -17
View File
@@ -220,25 +220,13 @@ SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# Email settings
def _get_email_env(name, default=''):
value = os.getenv(name)
if value is None:
return default
value = value.strip()
return '' if value.lower() in {'', 'false', 'none', 'null'} else value
EMAIL_ENABLED = _get_email_env('EMAIL_ENABLED').lower() in {'1', 'true', 'yes', 'on'}
EMAIL_SMTP_HOST = _get_email_env('EMAIL_SMTP_HOST')
EMAIL_ENABLED = bool(os.getenv('EMAIL_ENABLED', False))
EMAIL_SMTP_HOST = str(os.getenv('EMAIL_SMTP_HOST', False))
EMAIL_SMTP_PORT = int(os.getenv('EMAIL_SMTP_PORT', 587))
EMAIL_USE_TLS = True
EMAIL_USERNAME = _get_email_env('EMAIL_USERNAME')
EMAIL_PASSWORD = _get_email_env('EMAIL_PASSWORD')
EMAIL_FROM_ADDRESS = (
_get_email_env('EMAIL_FROM_ADDRESS')
or _get(_conf, ['email', 'from_address'], '')
or EMAIL_USERNAME
)
EMAIL_USERNAME = str(os.getenv('EMAIL_USERNAME', False))
EMAIL_PASSWORD = str(os.getenv('EMAIL_PASSWORD', False))
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
EMAIL_DEFAULT_SENDER_NAME = "Invario Inventarsystem Sender"
EMAIL_TIMEOUT_SECONDS = 20
+3 -4
View File
@@ -431,15 +431,14 @@ def check_password_strength(password):
if password is None:
return False
if len(password) < 12:
if len(password) < 5:
return False
has_lower = any(char.islower() for char in password)
has_upper = any(char.isupper() for char in password)
has_digit = any(char.isdigit() for char in password)
has_symbol = any(not char.isalnum() for char in password)
if not (has_lower and has_upper and has_digit and has_symbol):
if not (has_lower and has_upper and has_digit):
return False
return True
@@ -698,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.
+9 -17
View File
@@ -3,32 +3,25 @@ from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import smtplib
import time
import logging
import Web.modules.database.settings as cfg
logger = logging.getLogger(__name__)
def _build_smtp_client():
if not cfg.EMAIL_SMTP_HOST:
raise RuntimeError('EMAIL_SMTP_HOST ist nicht konfiguriert')
smtp = smtplib.SMTP(
cfg.EMAIL_SMTP_HOST,
cfg.EMAIL_SMTP_PORT,
timeout=cfg.EMAIL_TIMEOUT_SECONDS,
"mail.invario-software.de",
587,
timeout=10,
)
smtp.ehlo()
if cfg.EMAIL_USE_TLS:
if True:
smtp.starttls()
smtp.ehlo()
if cfg.EMAIL_USERNAME:
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
smtp.login("no-reply@invario-software.de", "eSpage,65,{")
return smtp
def _normalize_recipients(email: list | str) -> list[str]:
if isinstance(email, str):
email = email.replace(';', ',').split(',')
@@ -38,12 +31,11 @@ def _normalize_recipients(email: list | str) -> list[str]:
def _send_message(email: list | str, subject: str, note: str, sender: str, attachment=None) -> bool:
"""Send a plain/HTML message, optionally with one PDF attachment."""
if not cfg.MODULES.is_enabled("mail"):
logger.info("Email delivery skipped because the mail module is disabled")
print("Debug: Module not enabled")
return False
recipients = _normalize_recipients(email)
if not recipients:
logger.warning("Email delivery skipped because no recipients were provided")
return False
body_message = note
@@ -84,7 +76,7 @@ def _send_message(email: list | str, subject: str, note: str, sender: str, attac
msg = MIMEMultipart("mixed" if attachment else "alternative")
msg["Subject"] = str(subject)
from_address = cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME or "no-reply@invario-software.de"
from_address = "no-reply@invario-software.de"
msg["From"] = f"{sender} <{from_address}>"
msg["To"] = str(recipient)
@@ -112,7 +104,7 @@ def _send_message(email: list | str, subject: str, note: str, sender: str, attac
return True
except Exception as e:
logger.exception("Email delivery failed: %s", e)
print(f"Debug: Fehler beim Senden der E-Mail: {e}")
return False
finally:
if smtp:
+1 -1
View File
@@ -3,7 +3,7 @@ import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin
import Web.modules.database.user as us
from Web.modules.emailservice.email import send_pdf
from Web.modules.emailservice.email import send, send_pdf
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv
import io
+4
View File
@@ -227,6 +227,10 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
sent_count = 0
for subscription in subscriptions:
from Web.modules.emailservice.email import send, send_pdf
if cfg.MODULES.is_enabled('mail'):
#implement the sending
pass
success = _send_to_subscription(
subscription,
title,
+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>
+64 -58
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,27 +1342,23 @@
<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 %}
<!--
{% if current_permissions.pages.get('tutorial_page', False) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li>
{% endif %}
-->
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %}
<li class="nav-item">
<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">
@@ -1429,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>
@@ -1630,6 +1633,8 @@
</div>
</div>
<!--
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3>
@@ -1643,6 +1648,7 @@
</div>
</div>
</div>
-->
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
@@ -1662,7 +1668,7 @@
<option value="Benachrichtigungen"></option>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %}
<option value="Tutorial"></option>
<!--<option value="Tutorial"></option>-->
{% endif %}
{% endif %}
@@ -1765,10 +1771,10 @@
});
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
//const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
//const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast');
@@ -1789,9 +1795,9 @@
{% if current_permissions.pages.get('notifications_view', False) %}
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
{% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %}
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
{% endif %}
//{% if current_permissions.pages.get('tutorial_page', False) %}
//{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
//{% endif %}
{% endif %}
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
@@ -2042,11 +2048,11 @@
window.setInterval(pollNotificationStatus, 30000);
}
function showOnboarding(){
if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex';
}
}
//function showOnboarding(){
// if (onboardingOverlay) {
// onboardingOverlay.style.display = 'flex';
// }
//}
function hideOnboarding(){
if (onboardingOverlay) {
@@ -2054,19 +2060,19 @@
}
}
if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') {
showOnboarding();
}
}
//if (onboardingKey && !isTutorialPage && !isLoginPage) {
// const decision = localStorage.getItem(onboardingKey);
// if (!decision || decision === 'later') {
// showOnboarding();
// }
//}
document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started');
}
window.location.href = {{ url_for('tutorial_page')|tojson }};
});
//document.getElementById('onboarding-start')?.addEventListener('click', function(){
// if (onboardingKey) {
// localStorage.setItem(onboardingKey, 'started');
// }
// window.location.href = {{ url_for('tutorial_page')|tojson }};
//});
document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) {
@@ -2399,21 +2405,21 @@
<script>
(function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
//const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator);
}
indicator.style.display = active ? 'inline-flex' : 'none';
}
//function applyTooltipMode(active) {
// document.body.classList.toggle('tutorial-tooltips-active', active);
// let indicator = document.getElementById('tutorialTooltipIndicator');
// if (!indicator) {
// indicator = document.createElement('div');
// indicator.id = 'tutorialTooltipIndicator';
// indicator.className = 'tutorial-tooltip-indicator';
// indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
// document.body.appendChild(indicator);
// }
// indicator.style.display = active ? 'inline-flex' : 'none';
//}
applyTooltipMode(enabled);
})();
+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 %}
+6 -6
View File
@@ -274,24 +274,24 @@
{% if not show_library_features %}
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
<div class="filter-inputs">
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<h3>{{ filter_names.get('1', 'Fach') }}</h3>
<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>
{% endfor %}
</div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<h3>{{ filter_names.get('2', 'Jahrgangsstufe') }}</h3>
<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>
+19 -38
View File
@@ -374,7 +374,7 @@
<div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- NEU: Klassen-Filter -->
<!-- Klassen-Filter -->
<select id="class-filter">
<option value="all">Alle Klassen</option>
{% set classes = [] %}
@@ -407,7 +407,6 @@
<table class="library-table" id="loans-table">
<thead>
<tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
@@ -419,7 +418,6 @@
</tr>
</thead>
<tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% for e in loan_entries %}
<tr class="loan-row"
data-borrow-id="{{ e.id }}"
@@ -452,7 +450,6 @@
<td>
<div><strong>{{ e.user }}</strong></div>
</td>
<!-- NEU: Klasse Spalte -->
<td>
{% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span>
@@ -469,9 +466,6 @@
{% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div>
<!--{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %} -->
<div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div>
@@ -506,18 +500,9 @@
{% endif %}
{% if e.has_damage %}
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
{% endif %}
<!--{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');" style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<input type="text" name="correction_reason" value="Korrektur zu {{ e.invoice_number }}" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px; max-width:180px;">
<input type="text" name="amount_delta" placeholder="z.B. -{{ e.invoice_amount }}" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %}-->
{% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button>
@@ -541,7 +526,6 @@
<table class="library-table" id="damaged-table">
<thead>
<tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
@@ -574,7 +558,6 @@
<div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div>
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
</td>
</tr>
@@ -627,6 +610,13 @@
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="damage-invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="damage-invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
@@ -640,13 +630,16 @@
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button>
<button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Neues Modal: Reparatur-Optionen (Reparieren, Ersetzen mit neuem Code_4, Löschen) -->
<!-- Modal: Reparatur-Optionen -->
<div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
@@ -676,7 +669,7 @@
<script>
(function() {
const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt
const classFilter = document.getElementById('class-filter');
const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row'));
@@ -693,10 +686,8 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {};
// NEU: Sortier-Funktion
window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId);
const tbody = table.tBodies[0];
@@ -714,7 +705,6 @@
const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1;
// Pfeil im Header aktualisieren
const headers = table.querySelectorAll("th");
headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
@@ -726,19 +716,15 @@
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
}
// Zeilen sortieren
rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
});
// Neu einfügen
rows.forEach(row => tbody.appendChild(row));
};
// Funktionen für das Reparatur-Modal
window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal');
const form = document.getElementById('repair-action-form');
@@ -871,26 +857,25 @@
});
}
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value;
const damage = damageFilter.value;
const klasse = classFilter.value; // NEU
const klasse = classFilter.value;
let visibleLoans = 0;
loanRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU
const rowKlasse = row.dataset.klasse || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU
const classMatch = klasse === 'all' || rowKlasse === klasse;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU
const show = searchMatch && statusMatch && classMatch && damageMatch;
row.style.display = show ? '' : 'none';
if (show) visibleLoans++;
});
@@ -905,10 +890,6 @@
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch;
@@ -919,7 +900,7 @@
}
searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU
classFilter.addEventListener('change', applyFilters);
statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters);
applyFilters();
+175 -74
View File
@@ -8,9 +8,9 @@
1. Modals & Overlays
========================================= */
.modal {
display: none; /* Managed by JS (none/flex) */
display: none;
position: fixed;
inset: 0; /* Modern shorthand for top: 0, right: 0, bottom: 0, left: 0 */
inset: 0;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.6);
align-items: center;
@@ -23,7 +23,7 @@
display: flex;
flex-direction: column;
width: 100%;
max-width: 900px; /* Consolidated from conflicting 600px/900px rules */
max-width: 900px;
max-height: 90vh;
background-color: var(--ui-surface, #fff);
border-radius: 8px;
@@ -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;
@@ -419,31 +447,136 @@
}
/* =========================================
7. Mobile Responsiveness
7. Mobile Responsiveness (Updated for Cards)
========================================= */
@media (max-width: 768px) {
.library-table-container { padding: 10px; }
.library-search-bar { flex-direction: column; }
.library-search-input { width: 100%; }
.library-items-table { font-size: 0.85em; }
/* Enlarge touch targets */
.library-search-input,
.library-scan-controls select,
.library-scan-controls input,
.library-filter-toggle-btn,
.filter-item input,
.filter-item select,
.button {
min-height: 44px;
}
/* Stack Scan & Controls */
.library-scan-controls {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.library-scan-controls > * {
width: 100% !important;
margin-left: 0 !important;
}
.filter-row {
grid-template-columns: 1fr;
}
.filter-buttons {
flex-direction: column;
align-items: stretch;
}
.filter-buttons .button {
width: 100%;
justify-content: center;
margin-bottom: 8px;
}
.filter-buttons > div[style*="flex-grow"] {
display: none;
}
/* Convert Table to Cards */
.library-items-table,
.library-items-table thead,
.library-items-table tbody,
.library-items-table th,
.library-items-table td,
.library-items-table tr {
display: block;
width: 100%;
}
.library-items-table thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
.library-items-table tr {
margin-bottom: 16px;
border: 1px solid var(--ui-border, #ddd);
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
}
.library-items-table td {
padding: 8px 10px;
position: relative;
padding: 12px 16px 12px 35% !important;
text-align: right;
border-bottom: 1px solid #f4f4f4;
font-size: 0.95em;
min-height: auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.library-items-table td::before {
content: attr(data-label);
position: absolute;
left: 16px;
width: 30%;
text-align: left;
font-weight: 600;
color: #555;
white-space: nowrap;
}
/* Styles specific for the Actions cell */
.library-items-table td:last-child {
border-bottom: none;
padding: 16px !important;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
}
.library-items-table td:last-child::before {
display: none;
}
.table-actions {
flex-direction: column;
width: 100%;
gap: 10px;
}
.table-actions .button {
padding: 4px 8px;
font-size: 0.75em;
}
.filter-row,
.library-scan-controls {
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
padding: 12px;
font-size: 1em;
justify-content: center;
}
.library-scan-reader-wrap { max-width: 100%; }
}
.library-scan-reader-wrap {
display: none;
margin-top: 15px;
@@ -500,7 +633,6 @@
letter-spacing: 2px;
}
.detail-gallery-container {
display: flex;
flex-wrap: wrap;
@@ -534,13 +666,12 @@
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
<!-- Header -->
<!-- Search and Filter Toggle -->
<!-- Customizable Filter Panel -->
<div class="library-header">
<h1>📚 Bibliothek</h1>
<p>Bücher, CDs, DVDs und weitere Medien</p>
</div>
<!-- Student card / quick scan workflow -->
<!-- Search and Filter Toggle -->
<div class="library-search-bar">
<input
type="text"
@@ -556,26 +687,31 @@
🔍 Filter
</button>
</div>
<!-- Controls -->
<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;">
<div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;">
</div>
</div>
<!-- Customizable Filter Panel -->
<div id="filterPanel" class="library-filter-panel">
<div class="filter-row">
<div class="filter-item">
@@ -674,7 +810,7 @@
<script>
// =========================================================================
// CUSTOM MODAL HELPERS (Replaces native browser alert, confirm & prompt)
// CUSTOM MODAL HELPERS
// =========================================================================
function createModalOverlay() {
const overlay = document.createElement('div');
@@ -867,6 +1003,7 @@
input.select();
});
}
// =========================================================================
// 1. GLOBAL STATE DEFINITIONS
// =========================================================================
@@ -891,19 +1028,18 @@
let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false;
// Scanner Related State Variables
let scannerInstance = null;
let scannerRunning = false;
let activeScannerCallback = null;
let activeStudentCardId = '';
let lastScanValue = '';
let lastScanAt = 0;
// Keyboard-scanner support (physical scanners that act as keyboard wedges)
let keyboardScannerEnabled = false;
let keyboardScanBuffer = '';
let keyboardLastKeyAt = 0;
let physicalScannerModalOpen = false;
const KEYBOARD_SCAN_INTERCHAR_MS = 500; // max time between slower scanner keystrokes
const KEYBOARD_SCAN_INTERCHAR_MS = 500;
const physicalScanQueue = [];
let keyboardScanProcessing = false;
let editLibraryState = {
@@ -1040,13 +1176,14 @@
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
// Using data-label injection for accurate mobile rendering mapping
return `
<tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.ISBN || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td>
<td data-label="Titel" class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td data-label="ISBN/Code">${escapeHtml(item.ISBN || '-')}</td>
<td data-label="Typ">${getItemTypeLabel(item.ItemType || 'book')}</td>
<td data-label="Anzahl" style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td data-label="Status">
<span class="table-status ${statusClass}">
${statusText}
</span>
@@ -1056,7 +1193,7 @@
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
${actionLabel}
</button>
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff; margin-left:6px;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
</td>
</tr>`;
@@ -1125,26 +1262,22 @@
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
// 1. CRITICAL FIX: Make the container visible BEFORE Quagga initializes
// Quagga cannot calculate video dimensions if display is 'none'
if (scannerWrap) {
scannerWrap.style.display = 'block';
}
setScanStatus('Starte Kamera...', 'warn');
// 2. Initialize Quagga with a slight delay to allow the DOM to render the block
setTimeout(() => {
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
// This MUST match the inner div where the video should appear
target: document.querySelector('.library-scan-reader'),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Prefer back camera on mobile
facingMode: "environment"
}
},
locator: {
@@ -1152,13 +1285,11 @@
halfSample: true
},
decoder: {
// Keep only the barcode types you actually use to improve performance
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
},
locate: true
}, function(err) {
if (err) {
// This will finally log the actual error (e.g., NotAllowedError) if permissions fail
console.error("Quagga initialization failed:", err);
setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error');
@@ -1190,7 +1321,6 @@
scannerRunning = false;
activeScannerCallback = null;
// Hide the container to free up UI space
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
if (scannerWrap) {
scannerWrap.style.display = 'none';
@@ -1198,7 +1328,7 @@
const toggleBtn = document.getElementById('toggleScannerBtn');
if (toggleBtn) {
toggleBtn.textContent = 'Kamera Scanner'; // Reset button text
toggleBtn.textContent = 'Kamera Scanner';
}
}
@@ -1210,7 +1340,6 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Falls du in Flask CSRF-Protect nutzt, muss der Token mitgesendet werden:
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
}
@@ -1218,7 +1347,6 @@
if (!response.ok) return false;
// Wir parsen die Antwort. Flask jsonify({"is_student_card": true/false})
const data = await response.json();
return data === true || data.is_student_card === true;
@@ -1245,7 +1373,7 @@
});
// =========================================================================
// Keyboard scanner handling (physical scanners that send chars then Enter)
// Keyboard scanner handling
// =========================================================================
function getPhysicalScannerInstruction() {
const mode = document.getElementById('scanModeSelect')?.value || 'card_only';
@@ -1291,6 +1419,7 @@
keyboardScannerEnabled = false;
keyboardScanBuffer = '';
keyboardLastKeyAt = 0;
setActiveStudentCard('');
document.removeEventListener('keydown', keyboardScanKeydownHandler);
const modal = document.getElementById('physicalScannerModal');
if (modal) modal.style.display = 'none';
@@ -1299,7 +1428,6 @@
}
function keyboardScanKeydownHandler(e) {
// Only active when explicitly enabled
if (!keyboardScannerEnabled || !physicalScannerModalOpen) return;
if (e.key === 'Escape') {
@@ -1310,7 +1438,6 @@
const now = Date.now();
// If Enter/Return pressed -> finalize buffer
if (e.key === 'Enter') {
e.preventDefault();
const code = keyboardScanBuffer.trim();
@@ -1318,7 +1445,6 @@
keyboardLastKeyAt = 0;
if (!code) return;
// Scanner keystrokes must not be submitted into the focused form field.
const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = code;
updatePhysicalScannerModal('Enter erkannt. Code wird verarbeitet...', 'warn');
@@ -1326,9 +1452,7 @@
return;
}
// Accept all printable scanner characters, including letters and punctuation.
if (e.key.length === 1) {
// If time gap too big, start new buffer
if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) {
keyboardScanBuffer = '';
}
@@ -1336,7 +1460,6 @@
keyboardLastKeyAt = now;
const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = keyboardScanBuffer;
// Prevent default so scanner input is captured even while an input has focus.
e.preventDefault();
}
}
@@ -1365,21 +1488,18 @@
const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return; // Verhindert doppeltes Scannen in kurzer Zeit
return;
}
lastScanValue = scannedCode;
lastScanAt = now;
// Den aktuellen Modus aus dem Dropdown auslesen
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
// 1. Modus: Nur Rückgabe
if (mode === 'return_only') {
await returnByCode(scannedCode);
return;
}
// 2. Modus: Nur Ausweis
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Ausweis scannen.`, 'ok');
@@ -1388,13 +1508,11 @@
return;
}
// 3. Modus: Schnellmodus (1x Ausweis, 1x Buch)
if (mode === 'quick_toggle') {
await processQuickToggleScan(scannedCode);
return;
}
// 4. Modus: Dauermodus (1x Ausweis, Nx Bücher)
if (mode === 'continuous') {
await processContinuousScan(scannedCode);
return;
@@ -1471,7 +1589,6 @@
updatePhysicalScannerModal('Aktion erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
}
// Tabellen-Ansicht aktualisieren
if (typeof loadLibraryItems === "function") {
await loadLibraryItems();
}
@@ -1491,7 +1608,6 @@
async function processQuickToggleScan(scannedCode) {
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
if (!activeStudentCardId) {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Mediencode scannen.`, 'ok');
@@ -1500,7 +1616,6 @@
return;
}
// 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden)
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
@@ -1695,7 +1810,6 @@
}
function showSmallConfirm(message, kind='ok', action = null) {
// Append the small helper text in German
const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.';
const el = document.createElement('div');
el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`;
@@ -1720,12 +1834,10 @@
closeButton.innerHTML = '&times;';
el.appendChild(closeButton);
document.body.appendChild(el);
// close handler
closeButton.addEventListener('click', () => {
if (el && el.parentNode) el.parentNode.removeChild(el);
});
// auto remove after 3 seconds
setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000);
setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 15000);
}
async function startNextStudentCardScan() {
@@ -1764,11 +1876,9 @@
const detailContent = document.getElementById('detailContent');
const detailModal = document.getElementById('detailModal');
// Lade-Status anzeigen und Modal öffnen
detailContent.innerHTML = '<p>Lade Details...</p>';
detailModal.style.display = 'flex';
// Daten vom Backend-API-Endpoint abrufen
fetch(`/api/item_detail/${itemId}`)
.then(response => {
if (!response.ok) {
@@ -1832,15 +1942,12 @@
});
}
// Schließt das Modal über den 'x'-Button
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
}
// Closes the modal if the user clicks the dark background overlay
window.onclick = function(event) {
const modal = document.getElementById('detailModal');
// If the click happened directly on the dark overlay (not the white box)
if (event.target === modal) {
closeDetailModal();
}
@@ -1905,12 +2012,10 @@
}
}
// Run when DOM structure is entirely ready
document.addEventListener('DOMContentLoaded', async () => {
wireScannerUi(); // Setup scanner control buttons
loadLibraryItems(); // Fetch your database items right away!
wireScannerUi();
loadLibraryItems();
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
const filterToggleBtn = document.getElementById('filterToggleBtn');
if (filterToggleBtn) {
filterToggleBtn.addEventListener('click', () => {
@@ -1968,15 +2073,11 @@
return;
}
// Leitet den manuellen Code an die zentrale Logik weiter,
// die prüft, welcher Modus im Dropdown aktiv ist.
handleScanSuccess(code);
// Feld nach Eingabe leeren, um direkt den nächsten Code bereitzuhaben
manualItemCode.value = '';
});
// Optional: Auch auf "Enter" im Textfeld reagieren
manualItemCode.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
+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);
}
+271 -60
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">
@@ -2420,19 +2576,23 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span>
<span>Ort</span>
<span>{{ filter_names.get('2', 'Fach') }}</span>
<span>{{ filter_names.get('1', 'Jahrgangsstufe') }}</span>
<span>{{ filter_names.get('1', 'Fach') }}</span>
<span>{{ filter_names.get('2', 'Jahrgangsstufe') }}</span>
<span>{{ filter_names.get('3', 'Schlagwort') }}</span>
<span>Barcode</span>
<span>Anzahl</span>
</div>
<div id="items-container" class="items-container">
<!-- Items will be dynamically loaded here -->
</div>
<div class="navigation-buttons">
<button class="prev-button" onclick="scrollPrev()">&#10094;</button>
<button class="next-button" onclick="scrollNext()">&#10095;</button>
<div class="items-navigation">
<div class="navigation-button-slot">
<button class="navigation-button prev-button" type="button" onclick="scrollPrev()" aria-label="Vorherige Elemente">&#10094;</button>
</div>
<div id="items-container" class="items-container">
<!-- Items will be dynamically loaded here -->
</div>
<div class="navigation-button-slot">
<button class="navigation-button next-button" type="button" onclick="scrollNext()" aria-label="Nächste Elemente">&#10095;</button>
</div>
</div>
</div>
<div id="item-modal" class="item-modal">
@@ -3206,9 +3366,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function loadItems(offset = 0, append = false) {
function loadItems(offset = 0, append = false, searchTerm = '', searchScope = 'all') {
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true`)
const searchParams = searchTerm
? `&search=${encodeURIComponent(searchTerm)}&search_scope=${encodeURIComponent(searchScope)}`
: '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true${searchParams}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3241,7 +3404,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB);
});
@@ -3285,6 +3448,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
card.dataset.code = (item.Code_4 !== undefined && item.Code_4 !== null) ?
String(item.Code_4).trim() : '';
card.dataset.groupCodes = JSON.stringify(Array.isArray(item.GroupedAllCodes) ? item.GroupedAllCodes : []);
card.dataset.groupedUnits = JSON.stringify(Array.isArray(item.GroupedUnitStates) ? item.GroupedUnitStates : []);
// Add filter values to their respective sets
filter1Array.forEach(value => {
@@ -3394,9 +3558,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="card-content" data-item-id="${item._id}">
<h3 class="item-col-name">${item.Name}</h3>
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-filter1"><strong>{{ filter_names.get('1', 'Jahrgangsstufe') }}:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>{{ filter_names.get('2', 'Jahrgangsstufe') }}:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>{{ filter_names.get('3', 'Jahrgangsstufe') }}:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
@@ -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;
+7 -8
View File
@@ -88,11 +88,10 @@
<div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen:</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-length" class="pw-rule">Mindestens 5 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
<li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li>
<li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li>
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul>
</div>
@@ -105,7 +104,7 @@
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}">
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{5,}">
<button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button>
</div>
</div>
@@ -201,11 +200,11 @@ function generateUsername() {
// PASSWORT GENERATOR
function generateSecurePassword() {
const length = 16;
const charsetLower = "abcdefghijklmnopqrstuvwxyz";
const charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const charsetNum = "0123456789";
const charsetSym = "!@#$%^&*()_+~|}{[]:;?><,.-=";
const length = 5;
const charsetLower = "abcdefghijklmnpqrstuvwxyz";
const charsetUpper = "ABCDEFGHIJKLMNPQRSTUVWXYZ";
const charsetNum = "123456789";
const charsetSym = "!#$(),.=";
let password = "";
// Garantiert mindestens 1 Zeichen aus jeder Kategorie
+122 -15
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) {
@@ -473,7 +512,9 @@
<div>
<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 Name, Nachname, Klasse, Ausweis-ID, lokales Differenzierungsmerkmal, Notizen & Ausleihdauer.
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';