Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5954ce8cb | |||
| 06318d0d2d | |||
| 5ed3c906dd | |||
| 8ce74d3e62 | |||
| ee25c46ff3 | |||
| f7fe2fcc11 | |||
| 4449007a15 | |||
| 6770d42f99 | |||
| 333f9fc89a | |||
| a286236834 | |||
| 84cc8de833 | |||
| 4b3c7e646f | |||
| f9b013508c | |||
| 2a6c40eb5a | |||
| 432d6da798 | |||
| 2cdf0d2169 | |||
| 9a4d10def7 | |||
| 10ae98245c | |||
| 359a8f8ab1 | |||
| 3171455f55 | |||
| a35bb3e048 | |||
| a0409a03dc | |||
| 627b1b76c4 | |||
| cd199f105d | |||
| f77f7024dd | |||
| 5202c5e25e | |||
| 2b1b01f4c1 | |||
| 55c21ac851 | |||
| 8069c6011c | |||
| 4535800a34 | |||
| e3aea734bb | |||
| ad77514574 | |||
| 9870726c76 | |||
| 683059f3de | |||
| ffab9b7fdf | |||
| 2f2c71d0a4 | |||
| 5388562b46 | |||
| df9367a19d | |||
| 0f0f0ebefd | |||
| 65fadf7f5f | |||
| 6f9994c4ce | |||
| 6496716572 | |||
| f750739b51 | |||
| 4c3545f4ba | |||
| c0bf0c0b8e | |||
| 2cd95ef808 | |||
| 17b58ff7ca | |||
| c67f64a45a | |||
| 7727bfd94a | |||
| 55f34e0462 |
+818
-197
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,8 @@ import json
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
import Web.modules.inventarsystem.data_protection as dp
|
||||
import Web.modules.database.user as us
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def _get_client():
|
||||
@@ -79,7 +81,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
|
||||
if original_status == 'completed':
|
||||
return 'completed'
|
||||
|
||||
current_time = datetime.datetime.now()
|
||||
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
start_time = ausleihung.get('Start')
|
||||
end_time = ausleihung.get('End')
|
||||
|
||||
@@ -137,7 +139,7 @@ def create_backup_database():
|
||||
os.makedirs(backup_dir)
|
||||
|
||||
# Aktuelles Datum für den Dateinamen
|
||||
current_date = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
current_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')
|
||||
backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json')
|
||||
|
||||
# Ausleihungen abrufen und als JSON speichern
|
||||
@@ -175,7 +177,7 @@ def create_backup_database():
|
||||
|
||||
log_file = os.path.join(log_dir, 'ausleihungen_error.log')
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n")
|
||||
f.write(f"{datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n")
|
||||
|
||||
print(f"Fehler beim Erstellen des Backups: {e}")
|
||||
return False
|
||||
@@ -183,7 +185,7 @@ 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):
|
||||
def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
|
||||
"""
|
||||
Add a new borrowing record for an item.
|
||||
|
||||
@@ -204,7 +206,7 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
ausleihungen = db['ausleihungen']
|
||||
|
||||
|
||||
ausleihung = {
|
||||
'Item': item_id,
|
||||
'User': dp.encrypt_text(user),
|
||||
@@ -223,6 +225,9 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
|
||||
|
||||
if exemplar_data:
|
||||
ausleihung['ExemplarData'] = exemplar_data
|
||||
|
||||
if due_date:
|
||||
ausleihung['DueDate'] = due_date
|
||||
|
||||
result = ausleihungen.insert_one(ausleihung)
|
||||
ausleihung_id = result.inserted_id
|
||||
@@ -270,7 +275,7 @@ def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, note
|
||||
return True
|
||||
|
||||
# UTC Zeitstempel nutzen
|
||||
update_data['LastUpdated'] = datetime.datetime.now(datetime.timezone.utc)
|
||||
update_data['LastUpdated'] = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
result = ausleihungen.update_one(
|
||||
{'_id': doc_id},
|
||||
@@ -300,7 +305,7 @@ def complete_ausleihung(id, end_time=None):
|
||||
"""
|
||||
try:
|
||||
if end_time is None:
|
||||
end_time = datetime.datetime.now()
|
||||
end_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
@@ -312,7 +317,7 @@ def complete_ausleihung(id, end_time=None):
|
||||
{'$set': {
|
||||
'End': end_time,
|
||||
'Status': 'completed',
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
|
||||
@@ -320,7 +325,7 @@ def complete_ausleihung(id, end_time=None):
|
||||
{'_id': ObjectId(id)},
|
||||
{'$set': {
|
||||
'Verfuegbar': True,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
|
||||
@@ -351,7 +356,7 @@ def cancel_ausleihung(id):
|
||||
{'_id': ObjectId(id)},
|
||||
{'$set': {
|
||||
'Status': 'cancelled',
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
|
||||
@@ -376,7 +381,7 @@ def remove_ausleihung(id):
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
ausleihungen = db['ausleihungen']
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
result = ausleihungen.update_one(
|
||||
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
|
||||
{'$set': {
|
||||
@@ -770,7 +775,7 @@ def activate_ausleihung(id):
|
||||
{'_id': doc_id, 'Status': 'planned'},
|
||||
{'$set': {
|
||||
'Status': 'active',
|
||||
'LastUpdated': datetime.datetime.now(datetime.timezone.utc)
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
return result.modified_count > 0
|
||||
@@ -794,7 +799,7 @@ def reset_item_completely(item_id):
|
||||
return {'success': False, 'message': 'Item nicht gefunden'}
|
||||
|
||||
item_name = item.get('Name', 'Unbekannt')
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_utc = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
# 1. Bulk update active borrowings
|
||||
update_res = ausleihungen_collection.update_many(
|
||||
@@ -862,7 +867,7 @@ def mark_booking_active(booking_id, ausleihung_id=None):
|
||||
doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id
|
||||
update_data = {
|
||||
'Status': 'active',
|
||||
'LastUpdated': datetime.datetime.now(datetime.timezone.utc)
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
if ausleihung_id:
|
||||
update_data['AusleihungId'] = ausleihung_id
|
||||
|
||||
@@ -26,6 +26,7 @@ import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
import Web.modules.inventarsystem.data_protection as dp
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def is_library_item(item):
|
||||
@@ -177,8 +178,8 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
||||
'SeriesPosition': series_position,
|
||||
'IsGroupedSubItem': is_grouped_sub_item,
|
||||
'ParentItemId': parent_item_id,
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
result = items.insert_one(item)
|
||||
item_id = result.inserted_id
|
||||
@@ -208,8 +209,8 @@ def remove_item(id):
|
||||
{'_id': ObjectId(id), 'Deleted': {'$ne': True}},
|
||||
{'$set': {
|
||||
'Deleted': True,
|
||||
'DeletedAt': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now(),
|
||||
'DeletedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'Verfuegbar': False,
|
||||
}}
|
||||
)
|
||||
@@ -304,7 +305,7 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
|
||||
'is_library': is_lib,
|
||||
'library_category': library_category,
|
||||
'Verfuegbar': bool(verfuegbar),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
|
||||
specific_update = shared_update.copy()
|
||||
@@ -346,7 +347,7 @@ def update_item_status(id, verfuegbar, user=None):
|
||||
|
||||
update_data = {
|
||||
'Verfuegbar': verfuegbar,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
|
||||
update_query = {'$set': update_data}
|
||||
@@ -387,7 +388,7 @@ def update_item_exemplare_status(id, exemplare_status):
|
||||
|
||||
update_data = {
|
||||
'ExemplareStatus': exemplare_status,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
|
||||
result = items.update_one(
|
||||
@@ -734,7 +735,7 @@ def unstuck_item(id):
|
||||
{'$set': {
|
||||
'Status': 'cancelled',
|
||||
'CancelledReason': 'unstuck_reset',
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
|
||||
@@ -745,7 +746,7 @@ def unstuck_item(id):
|
||||
{
|
||||
'$set': {
|
||||
'Verfuegbar': True,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
},
|
||||
'$unset': {'User': ""}
|
||||
}
|
||||
@@ -910,9 +911,9 @@ def get_filter_names():
|
||||
if names_doc and 'names' in names_doc:
|
||||
return names_doc['names']
|
||||
return {
|
||||
'1': 'Fach/Kategorie',
|
||||
'2': 'System/Bereich',
|
||||
'3': 'Typ/Art'
|
||||
'1': 'Jahrgangsstufe',
|
||||
'2': 'Fachgebiet',
|
||||
'3': 'Schlagwort'
|
||||
}
|
||||
|
||||
def set_filter_name(filter_num, name):
|
||||
@@ -1075,7 +1076,7 @@ def update_item_next_appointment(item_id, appointment_data):
|
||||
if appointment_data is None:
|
||||
update_query = {
|
||||
'$unset': {'NextAppointment': ""},
|
||||
'$set': {'LastUpdated': datetime.datetime.now()}
|
||||
'$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}
|
||||
}
|
||||
else:
|
||||
# Create a copy so we don't mutate the original dictionary passed in
|
||||
@@ -1088,7 +1089,7 @@ def update_item_next_appointment(item_id, appointment_data):
|
||||
update_query = {
|
||||
'$set': {
|
||||
'NextAppointment': data_to_save,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,7 +1122,7 @@ def clear_item_next_appointment(item_id):
|
||||
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(item_id)},
|
||||
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}}
|
||||
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
|
||||
@@ -23,6 +23,7 @@ from Web.modules.database.settings import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import ast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def _get_tenant_db(client):
|
||||
@@ -99,8 +100,8 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
||||
'calendar_enabled': bool(calendar_enabled),
|
||||
'clients_per_slot': clients_p_slot,
|
||||
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
result = items.insert_one(item)
|
||||
return result.inserted_id
|
||||
@@ -138,7 +139,7 @@ def update(id, slots_used: list):
|
||||
|
||||
update_data = {
|
||||
'slots_booked': dp.encrypt_text(str(slots_used)),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
|
||||
result = items.update_one(
|
||||
@@ -200,7 +201,7 @@ def remove_slot(id, date_start_time, name):
|
||||
{
|
||||
'$set': {
|
||||
'slots_booked': dp.encrypt_text(str(updated_slots)),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -740,6 +740,87 @@ def get_user_by_student_ident(student_ident):
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_student_name(student_name):
|
||||
"""Return user dict by student name by decrypting all cards and matching."""
|
||||
if not student_name:
|
||||
return None
|
||||
|
||||
# Normalisiere den Suchbegriff, den wir finden wollen
|
||||
normalized_target = str(student_name).strip()
|
||||
if not normalized_target:
|
||||
return None
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
db = _get_tenant_db(client)
|
||||
all_student_cards = db['student_cards'].find()
|
||||
|
||||
for user_doc in all_student_cards:
|
||||
encrypted_student_name = user_doc.get('SchülerName')
|
||||
|
||||
if encrypted_student_name:
|
||||
try:
|
||||
decrypted_ident = dp.decrypt_text(encrypted_student_name)
|
||||
|
||||
if decrypted_ident and str(decrypted_ident).strip() == normalized_target:
|
||||
return user_doc
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Datenbankfehler in get_user_by_student_name: {exc}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return None
|
||||
|
||||
def student_is_blocked(student_id):
|
||||
"""
|
||||
Überprüft, ob ein Schüler (Nutzer) im System gesperrt ist (z. B. aufgrund von überfälligen Ausleihen).
|
||||
Akzeptiert entweder den Benutzernamen oder die Schülerausweis-ID.
|
||||
|
||||
Rückgabewert:
|
||||
True, wenn der Nutzer gesperrt ist.
|
||||
False, wenn der Nutzer nicht gesperrt ist oder nicht gefunden wurde.
|
||||
"""
|
||||
if not student_id:
|
||||
return False
|
||||
|
||||
normalized_card_id = normalize_student_card_id(student_id)
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
|
||||
# Suche nach dem Benutzer anhand des Benutzernamens (groß/klein) oder der StudentCardId
|
||||
query = {
|
||||
'$or': [
|
||||
{'Username': student_id},
|
||||
{'username': student_id},
|
||||
{'StudentCardId': normalized_card_id}
|
||||
]
|
||||
}
|
||||
|
||||
user_doc = users.find_one(query)
|
||||
|
||||
if user_doc:
|
||||
# Lese das Feld 'is_blocked' aus, standardmäßig False, falls es nicht existiert
|
||||
return bool(user_doc.get('is_blocked', False))
|
||||
|
||||
# Wenn kein Nutzer gefunden wurde, gehen wir sicherheitshalber davon aus, dass keine Sperre vorliegt
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Datenbankfehler in student_is_blocked: {e}")
|
||||
# Im Fehlerfall False zurückgeben, um Systemblockaden durch Datenbankfehler zu vermeiden
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def make_admin(username):
|
||||
"""Grant administrator privileges to a user."""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
|
||||
@@ -17,7 +17,7 @@ def _build_smtp_client():
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USERNAME:
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
|
||||
smtp.login("no-reply@invario-software.de", "#,EATwIn,68" or "")
|
||||
return smtp
|
||||
|
||||
|
||||
@@ -68,14 +68,20 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = str(subject)
|
||||
msg["From"] = f"{sender} <{cfg.EMAIL_USERNAME}>"
|
||||
msg["From"] = f"{sender} <no-reply@invario-software.de>"
|
||||
msg["To"] = str(recipient)
|
||||
|
||||
msg.attach(MIMEText(text_content, "plain"))
|
||||
msg.attach(MIMEText(html_content, "html"))
|
||||
|
||||
#smtp.sendmail(
|
||||
# from_addr=cfg.EMAIL_USERNAME,
|
||||
# to_addrs=[recipient],
|
||||
# msg=msg.as_string()
|
||||
#)
|
||||
|
||||
smtp.sendmail(
|
||||
from_addr=cfg.EMAIL_USERNAME,
|
||||
from_addr="no-reply@invario-software.de",
|
||||
to_addrs=[recipient],
|
||||
msg=msg.as_string()
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from bson import ObjectId
|
||||
import requests
|
||||
import hashlib
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
@@ -153,7 +154,7 @@ def save_push_subscription(username, subscription_obj):
|
||||
subs_col.update_one(
|
||||
{'_id': existing['_id']},
|
||||
{'$set': {
|
||||
'LastUsed': datetime.datetime.now(),
|
||||
'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'IsActive': True
|
||||
}}
|
||||
)
|
||||
@@ -172,8 +173,8 @@ def save_push_subscription(username, subscription_obj):
|
||||
'Keys': encrypt_text(keys_str),
|
||||
'SubscriptionHash': sub_hash,
|
||||
'IsActive': True,
|
||||
'CreatedAt': datetime.datetime.now(),
|
||||
'LastUsed': datetime.datetime.now(),
|
||||
'CreatedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
|
||||
'UserAgent': subscription_obj.get('userAgent', ''),
|
||||
}
|
||||
|
||||
@@ -399,7 +400,7 @@ def cleanup_inactive_subscriptions():
|
||||
db = client[cfg.MONGODB_DB]
|
||||
subs_col = get_push_subscriptions_collection(db)
|
||||
|
||||
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30)
|
||||
cutoff_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")) - datetime.timedelta(days=30)
|
||||
|
||||
result = subs_col.delete_many({
|
||||
'IsActive': False,
|
||||
|
||||
@@ -78,15 +78,15 @@
|
||||
<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', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
|
||||
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
|
||||
</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', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
|
||||
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Fach') }}" placeholder="z. B. Fach">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="filter_name_3">Name Filter 3</label>
|
||||
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
|
||||
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Schlagwort') }}" placeholder="z. B. Schlagwort">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1373,6 +1373,7 @@
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
{% if current_permissions.pages.get('library_loans_admin', False) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Alle Ausleihen/Alle Defekten Items</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('mahnungen_admin') }}">Mahnungen</a></li>
|
||||
{% endif %}
|
||||
{% if student_cards_module_enabled %}
|
||||
{% if current_permissions.actions.get('can_manage_users', False) %}
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
{% if not show_library_features %}
|
||||
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
|
||||
<div class="filter-inputs">
|
||||
<h3>Unterrichtsfach (Filter 1):</h3>
|
||||
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
|
||||
<div class="multi-filter">
|
||||
{% for idx in range(4) %}
|
||||
<div class="form-group">
|
||||
@@ -286,7 +286,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3>Jahrgangsstufe (Filter 2):</h3>
|
||||
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
|
||||
<div class="multi-filter">
|
||||
{% for idx in range(4) %}
|
||||
<div class="form-group">
|
||||
@@ -298,7 +298,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3>Schlagwort (Filter 3):</h3>
|
||||
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
|
||||
<div class="multi-filter">
|
||||
{% for idx in range(4) %}
|
||||
<div class="form-group">
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
background: var(--ui-surface-soft);
|
||||
user-select: none; /* Verhindert Textmarkierung beim Klicken */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.library-table tr:hover td {
|
||||
@@ -151,7 +151,7 @@
|
||||
.badge-open { background: #fee2e2; color: #991b1b; }
|
||||
.badge-paid { background: #dcfce7; color: #166534; }
|
||||
.badge-damaged { background: #fee2e2; color: #991b1b; }
|
||||
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */
|
||||
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
@@ -168,6 +168,32 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions .btn-outline-danger {
|
||||
color: #dc2626;
|
||||
background-color: #fef2f2; /* Light red tint so it doesn't blend into the white table */
|
||||
border: 2px solid #dc2626; /* Thicker, clear border */
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1); /* Subtle shadow for depth */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.row-actions .btn-outline-danger:hover {
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
|
||||
transform: translateY(-1px); /* Slight lift effect on hover */
|
||||
}
|
||||
|
||||
.row-actions .btn-outline-danger:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.15); /* Pressed state */
|
||||
}
|
||||
/* ------------------------------------------------------ */
|
||||
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 0.92rem;
|
||||
|
||||
@@ -614,6 +614,199 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
|
||||
// =========================================================================
|
||||
// CUSTOM MODAL HELPERS (Replaces native browser alert, confirm & prompt)
|
||||
// =========================================================================
|
||||
function createModalOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.zIndex = '999999';
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createModalBox() {
|
||||
const box = document.createElement('div');
|
||||
box.style.backgroundColor = '#fff';
|
||||
box.style.padding = '24px';
|
||||
box.style.borderRadius = '8px';
|
||||
box.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
|
||||
box.style.fontFamily = 'sans-serif';
|
||||
box.style.minWidth = '320px';
|
||||
box.style.maxWidth = '90%';
|
||||
box.style.textAlign = 'left';
|
||||
box.style.color = '#333';
|
||||
return box;
|
||||
}
|
||||
|
||||
function customAlert(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createModalOverlay();
|
||||
const box = createModalBox();
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.marginBottom = '20px';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.style.display = 'flex';
|
||||
btnContainer.style.justifyContent = 'flex-end';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'OK';
|
||||
btn.style.padding = '8px 16px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.border = 'none';
|
||||
btn.style.backgroundColor = '#007bff';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.borderRadius = '4px';
|
||||
|
||||
btn.onclick = () => {
|
||||
document.body.removeChild(overlay);
|
||||
resolve();
|
||||
};
|
||||
|
||||
btnContainer.appendChild(btn);
|
||||
box.appendChild(text);
|
||||
box.appendChild(btnContainer);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
btn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function customConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createModalOverlay();
|
||||
const box = createModalBox();
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.marginBottom = '20px';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.style.display = 'flex';
|
||||
btnContainer.style.justifyContent = 'flex-end';
|
||||
btnContainer.style.gap = '10px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Abbrechen';
|
||||
cancelBtn.style.padding = '8px 16px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.border = '1px solid #ccc';
|
||||
cancelBtn.style.backgroundColor = '#f8f9fa';
|
||||
cancelBtn.style.color = '#333';
|
||||
cancelBtn.style.borderRadius = '4px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = 'OK';
|
||||
okBtn.style.padding = '8px 16px';
|
||||
okBtn.style.cursor = 'pointer';
|
||||
okBtn.style.border = 'none';
|
||||
okBtn.style.backgroundColor = '#007bff';
|
||||
okBtn.style.color = '#fff';
|
||||
okBtn.style.borderRadius = '4px';
|
||||
|
||||
const closeAndResolve = (val) => {
|
||||
document.body.removeChild(overlay);
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => closeAndResolve(false);
|
||||
okBtn.onclick = () => closeAndResolve(true);
|
||||
|
||||
btnContainer.appendChild(cancelBtn);
|
||||
btnContainer.appendChild(okBtn);
|
||||
box.appendChild(text);
|
||||
box.appendChild(btnContainer);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function customPrompt(message, defaultValue = '') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createModalOverlay();
|
||||
const box = createModalBox();
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.marginBottom = '15px';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = defaultValue;
|
||||
input.style.width = '100%';
|
||||
input.style.marginBottom = '20px';
|
||||
input.style.padding = '10px';
|
||||
input.style.boxSizing = 'border-box';
|
||||
input.style.border = '1px solid #ccc';
|
||||
input.style.borderRadius = '4px';
|
||||
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.style.display = 'flex';
|
||||
btnContainer.style.justifyContent = 'flex-end';
|
||||
btnContainer.style.gap = '10px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Abbrechen';
|
||||
cancelBtn.style.padding = '8px 16px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.border = '1px solid #ccc';
|
||||
cancelBtn.style.backgroundColor = '#f8f9fa';
|
||||
cancelBtn.style.color = '#333';
|
||||
cancelBtn.style.borderRadius = '4px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = 'OK';
|
||||
okBtn.style.padding = '8px 16px';
|
||||
okBtn.style.cursor = 'pointer';
|
||||
okBtn.style.border = 'none';
|
||||
okBtn.style.backgroundColor = '#007bff';
|
||||
okBtn.style.color = '#fff';
|
||||
okBtn.style.borderRadius = '4px';
|
||||
|
||||
const closeAndResolve = (val) => {
|
||||
document.body.removeChild(overlay);
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => closeAndResolve(null);
|
||||
okBtn.onclick = () => closeAndResolve(input.value);
|
||||
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') closeAndResolve(input.value);
|
||||
if (e.key === 'Escape') closeAndResolve(null);
|
||||
};
|
||||
|
||||
btnContainer.appendChild(cancelBtn);
|
||||
btnContainer.appendChild(okBtn);
|
||||
|
||||
box.appendChild(text);
|
||||
box.appendChild(input);
|
||||
box.appendChild(btnContainer);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
// =========================================================================
|
||||
// 1. GLOBAL STATE DEFINITIONS
|
||||
// =========================================================================
|
||||
@@ -884,7 +1077,7 @@
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// This MUST match the inner div where the video should appear
|
||||
target: document.querySelector('.library-scan-reader'),
|
||||
target: document.querySelector('.library-scan-reader'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
@@ -897,7 +1090,7 @@
|
||||
},
|
||||
decoder: {
|
||||
// Keep only the barcode types you actually use to improve performance
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
||||
},
|
||||
locate: true
|
||||
}, function(err) {
|
||||
@@ -1243,31 +1436,39 @@
|
||||
// =========================================================================
|
||||
// 4. UI INTERACTIONS & UTILITIES
|
||||
// =========================================================================
|
||||
function borrowItem(itemId) {
|
||||
async function borrowItem(itemId) {
|
||||
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
||||
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
||||
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
||||
await customAlert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultCardId = activeStudentCardId || '';
|
||||
const cardId = (window.prompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
|
||||
const promptCardResult = await customPrompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId);
|
||||
if (promptCardResult === null) return;
|
||||
|
||||
const cardId = promptCardResult.trim().toUpperCase();
|
||||
if (!cardId) {
|
||||
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
|
||||
await customAlert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStudentCard(cardId);
|
||||
|
||||
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
|
||||
const promptDurationResult = await customPrompt('Ausleihdauer in Tagen (optional):');
|
||||
if (promptDurationResult === null) return;
|
||||
const durationInput = promptDurationResult.trim();
|
||||
|
||||
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
||||
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
|
||||
let borrowCount = parseInt(countPrompt || '1', 10);
|
||||
const countPromptResult = await customPrompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1');
|
||||
if (countPromptResult === null) return;
|
||||
|
||||
let borrowCount = parseInt(countPromptResult.trim() || '1', 10);
|
||||
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
||||
borrowCount = 1;
|
||||
}
|
||||
if (borrowCount > maxAvailable) {
|
||||
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
||||
await customAlert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Mahnungsübersicht{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.mahnungen-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mahnungen-head {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mahnungen-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.mahnungen-card {
|
||||
background: var(--ui-surface, #ffffff);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mahnungen-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.mahnungen-table th,
|
||||
.mahnungen-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mahnungen-table th {
|
||||
font-size: 0.83rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
background: var(--ui-surface-soft, #f8fafc);
|
||||
}
|
||||
|
||||
.mahnungen-table tr:hover td {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.badge-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-open { background: #fee2e2; color: #991b1b; }
|
||||
.badge-warning { background: #fef3c7; color: #b45309; }
|
||||
.badge-info { background: #e0f2fe; color: #0369a1; }
|
||||
.badge-paid { background: #dcfce7; color: #166534; }
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 36px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mahnungen-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle-fill me-2"></i>Mahnungs- und Ausleihverwaltung</h5>
|
||||
<span class="badge bg-light text-dark">Übersicht überfälliger Medien</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if overdue_items and overdue_items|length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Schüler / Ausweis</th>
|
||||
<th>Klasse</th>
|
||||
<th>Medium</th>
|
||||
<th>Fälligkeitsdatum</th>
|
||||
<th>Überfällig seit</th>
|
||||
<th>Mahnstufe</th>
|
||||
<th class="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in overdue_items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ item.schueler_name }}</strong><br>
|
||||
<small class="text-muted">{{ item.ausweis_id }}</small>
|
||||
</td>
|
||||
<td>{{ item.klasse }}</td>
|
||||
<td>
|
||||
{{ item.item_name }}<br>
|
||||
<small class="text-muted">Code: {{ item.item_code or '—' }}</small>
|
||||
</td>
|
||||
<td>{{ item.due_date }}</td>
|
||||
<td>
|
||||
<span class="badge bg-danger">{{ item.days_overdue }} Tage</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.mahnstufe == 2 %}
|
||||
<span class="badge bg-dark text-danger">Stufe 2 (Gesperrt)</span>
|
||||
{% elif item.mahnstufe == 1 %}
|
||||
<span class="badge bg-warning text-dark">Stufe 1</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Stufe 0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group" role="group">
|
||||
{% if mail_service_enabled %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')">
|
||||
<i class="bi bi-envelope"></i> E-Mail
|
||||
</button>
|
||||
{% 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
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-check-circle-fill text-success fs-1"></i>
|
||||
<p class="text-muted mt-2">Aktuell gibt es keine überfälligen Mahnungen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="emailForm">
|
||||
<input type="hidden" id="modalLoanId">
|
||||
<div class="mb-3">
|
||||
<label for="modalStudentName" class="form-label">Empfänger</label>
|
||||
<input type="text" class="form-control" id="modalStudentName" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="modalEmail" class="form-label">E-Mail-Adresse</label>
|
||||
<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>
|
||||
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitEmailMahnung()">E-Mail senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEmailModal(loanId, studentName, studentEmail) {
|
||||
document.getElementById('modalLoanId').value = loanId;
|
||||
document.getElementById('modalStudentName').value = studentName;
|
||||
document.getElementById('modalEmail').value = studentEmail || '';
|
||||
document.getElementById('modalMessage').value = '';
|
||||
|
||||
var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
|
||||
myModal.show();
|
||||
}
|
||||
|
||||
function submitEmailMahnung() {
|
||||
const loanId = document.getElementById('modalLoanId').value;
|
||||
const email = document.getElementById('modalEmail').value;
|
||||
const message = document.getElementById('modalMessage').value;
|
||||
|
||||
if (!email) {
|
||||
alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/mahnungen_send_email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ loan_id: loanId, email: email, message: message })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('E-Mail erfolgreich gesendet!');
|
||||
var modalEl = document.getElementById('emailModal');
|
||||
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||
modal.hide();
|
||||
} else {
|
||||
alert('Fehler: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Fehler beim Senden der Anfrage.');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
function resetMahnung(loanId, studentName) {
|
||||
if (!confirm('Möchten Sie die Mahnung für "' + studentName + '" wirklich zurücksetzen?\n\nDadurch wird die Mahnstufe auf 0 gesetzt, die Frist um 14 Tage verlängert und der Schülerausweis entsperrt.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/mahnungen_reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ loan_id: loanId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
|
||||
} else {
|
||||
alert('Fehler: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Fehler beim Senden der Anfrage.');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2343,7 +2343,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<div class="filter-container">
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Unterrichtsfach:</label>
|
||||
<label>{{ filter_names.get('2', '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 +2357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Jahrgangsstufe:</label>
|
||||
<label>{{ filter_names.get('1', '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>
|
||||
@@ -2371,7 +2371,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Schlagwort:</label>
|
||||
<label>{{ filter_names.get('3', 'Schlagwort') }}</label>
|
||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||
</div>
|
||||
@@ -2420,9 +2420,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||
<span>Name</span>
|
||||
<span>Ort</span>
|
||||
<span>Unterrichtsfach</span>
|
||||
<span>Jahrgangsstufe</span>
|
||||
<span>Schlagwort</span>
|
||||
<span>{{ filter_names.get('2', 'Fach') }}</span>
|
||||
<span>{{ filter_names.get('1', 'Jahrgangsstufe') }}</span>
|
||||
<span>{{ filter_names.get('3', 'Schlagwort') }}</span>
|
||||
<span>Barcode</span>
|
||||
<span>Anzahl</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2>
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgangsstufe') }} (Filter 1)</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2>
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fachgebiet') }} (Filter 2)</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
|
||||
|
||||
@@ -781,7 +781,7 @@
|
||||
{% else %}
|
||||
<!-- Normal Item Mode: Standard Filters -->
|
||||
<div class="filter-inputs">
|
||||
<h3>Unterrichtsfach:</h3>
|
||||
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="filter1-1">Wert 1:</label>
|
||||
@@ -813,7 +813,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Jahrgangsstufe:</h3>
|
||||
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="filter2-1">Wert 1:</label>
|
||||
@@ -845,7 +845,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Schlagwort:</h3>
|
||||
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="filter3-1">Wert 1:</label>
|
||||
@@ -1531,22 +1531,22 @@
|
||||
}
|
||||
const isbnField = document.getElementById('isbn');
|
||||
const infoContainer = document.getElementById('book-info-container');
|
||||
|
||||
|
||||
if (!isbnField || !infoContainer) {
|
||||
alert('Fehler: Erforderliche Formularelemente nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const isbn = normalizeIsbnClient(isbnField.value);
|
||||
|
||||
|
||||
if (!isbn) {
|
||||
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Show loading indicator
|
||||
infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>';
|
||||
|
||||
|
||||
// Make API request to fetch book data
|
||||
fetch(`/fetch_book_info/${isbn}`)
|
||||
.then(response => {
|
||||
@@ -1564,10 +1564,10 @@
|
||||
infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Store book data globally for import functionality
|
||||
currentBookData = data;
|
||||
|
||||
|
||||
// Display book information with import button
|
||||
infoContainer.innerHTML = `
|
||||
<div class="book-info">
|
||||
@@ -1602,23 +1602,23 @@
|
||||
alert('Keine Buchdaten verfügbar zum Import.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Get form fields
|
||||
const nameField = document.getElementById('name');
|
||||
const descriptionField = document.getElementById('beschreibung');
|
||||
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
|
||||
|
||||
|
||||
if (!nameField || !descriptionField) {
|
||||
alert('Fehler: Formularfelder nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Generate book title
|
||||
let bookTitle = currentBookData.title || '';
|
||||
if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') {
|
||||
bookTitle += ` - ${currentBookData.authors}`;
|
||||
}
|
||||
|
||||
|
||||
// Generate description
|
||||
let description = '';
|
||||
if (currentBookData.description && currentBookData.description !== 'No description available') {
|
||||
@@ -1639,11 +1639,11 @@
|
||||
description += `\nSeiten: ${currentBookData.pageCount}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import the data into form fields
|
||||
nameField.value = bookTitle;
|
||||
descriptionField.value = description;
|
||||
|
||||
|
||||
// Preis in das Formularfeld eintragen
|
||||
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
|
||||
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
|
||||
@@ -1655,14 +1655,14 @@
|
||||
if (currentBookData.thumbnail) {
|
||||
downloadBookCover(currentBookData.thumbnail);
|
||||
}
|
||||
|
||||
|
||||
// Show success message
|
||||
const infoContainer = document.getElementById('book-info-container');
|
||||
const successMessage = document.createElement('div');
|
||||
successMessage.className = 'import-success-message';
|
||||
successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!';
|
||||
infoContainer.appendChild(successMessage);
|
||||
|
||||
|
||||
// Remove success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (successMessage.parentNode) {
|
||||
|
||||
@@ -53,8 +53,11 @@
|
||||
<option value="desc">Absteigend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-secondary flex-grow-1" onclick="resetFilters()">Zurücksetzen</button>
|
||||
<button class="btn btn-success flex-grow-1" onclick="exportFilteredUsers()">Exportieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small" id="filter-count"></div>
|
||||
@@ -82,19 +85,20 @@
|
||||
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
|
||||
<td class="actions">
|
||||
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
data-username="{{ user.username }}"
|
||||
data-firstname="{{ user.name if user.name else '' }}"
|
||||
data-lastname="{{ user.last_name if user.last_name else '' }}"
|
||||
onclick="openEditUserModal(this)">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning btn-sm"
|
||||
<button type="button" class="btn btn-warning btn-sm"
|
||||
onclick="openResetPasswordModal('{{ user.username }}')">
|
||||
Passwort zurücksetzen
|
||||
</button>
|
||||
@@ -124,6 +128,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="modal-body permissions-modal-body">
|
||||
<input type="hidden" id="perm-username" name="username">
|
||||
<div class="mb-3">
|
||||
@@ -180,6 +185,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin_update_user_name') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-username" name="username">
|
||||
<div class="mb-3">
|
||||
@@ -213,6 +219,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin_reset_user_password') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="reset-username" name="username">
|
||||
<div class="mb-3">
|
||||
@@ -313,6 +320,54 @@
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function exportFilteredUsers() {
|
||||
var table = document.getElementById('user-table');
|
||||
var tbody = table.querySelector('tbody');
|
||||
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
var visibleUsernames = [];
|
||||
|
||||
// Sammle alle Benutzernamen, die durch den Filter aktuell NICHT versteckt sind
|
||||
rows.forEach(function(row) {
|
||||
if (row.style.display !== 'none') {
|
||||
var cells = row.querySelectorAll('td');
|
||||
if (cells.length > 0) {
|
||||
visibleUsernames.push(cells[0].textContent.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleUsernames.length === 0) {
|
||||
alert('Keine Benutzer zum Exportieren vorhanden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamisches Formular erstellen
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = "{{ url_for('export_users_csv') }}";
|
||||
|
||||
// 1. CSRF-Token als Wert (ohne Klammern) übergeben
|
||||
var csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = "{{ csrf_token }}";
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
// 2. Gefilterte Benutzernamen hinzufügen
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'usernames';
|
||||
input.value = JSON.stringify(visibleUsernames);
|
||||
form.appendChild(input);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
|
||||
// Aufräumen
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyFilters();
|
||||
document.getElementById('filter-search').addEventListener('input', applyFilters);
|
||||
@@ -332,12 +387,12 @@
|
||||
var username = button.getAttribute('data-username');
|
||||
var name = button.getAttribute('data-firstname');
|
||||
var lastName = button.getAttribute('data-lastname');
|
||||
|
||||
|
||||
document.getElementById('edit-username').value = username;
|
||||
document.getElementById('edit-username-display').value = username;
|
||||
document.getElementById('edit-name').value = name;
|
||||
document.getElementById('edit-last-name').value = lastName;
|
||||
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
||||
modal.show();
|
||||
}
|
||||
@@ -345,8 +400,7 @@
|
||||
function openResetPasswordModal(username) {
|
||||
document.getElementById('reset-username').value = username;
|
||||
document.getElementById('username-display').value = username;
|
||||
|
||||
// Open modal using Bootstrap
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
+3
-2
@@ -17,6 +17,7 @@ import re
|
||||
import ipaddress
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -325,7 +326,7 @@ def get_tenant_trial_status(tenant_id=None, now=None):
|
||||
"expires_after_days", "ttl_days", or "days".
|
||||
"""
|
||||
trial_config = get_tenant_trial_config(tenant_id)
|
||||
now = now or datetime.datetime.now()
|
||||
now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
|
||||
if not enabled:
|
||||
@@ -447,7 +448,7 @@ def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
|
||||
|
||||
def purge_expired_trial_tenants(now=None):
|
||||
"""Delete expired trial tenants that opted into auto-delete."""
|
||||
now = now or datetime.datetime.now()
|
||||
now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
purged_tenants = []
|
||||
|
||||
for tenant_id in list(TENANT_REGISTRY.keys()):
|
||||
|
||||
Reference in New Issue
Block a user