Compare commits

...

27 Commits

Author SHA1 Message Date
Aiirondev_dev 432d6da798 Fix for the CRSF Token implementation
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 12:12:55 +02:00
Aiirondev_dev 2cdf0d2169 implementation of the export for users
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-24 11:56:03 +02:00
Aiirondev_dev 9a4d10def7 implementation of the Email add on check
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 22:14:54 +02:00
Aiirondev_dev 10ae98245c Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:59:16 +02:00
Aiirondev_dev 359a8f8ab1 Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 21:45:02 +02:00
Aiirondev_dev 3171455f55 Fix of a logik error
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:14:04 +02:00
Aiirondev_dev a35bb3e048 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:06:46 +02:00
Aiirondev_dev a0409a03dc Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 21:01:20 +02:00
Aiirondev_dev 627b1b76c4 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 20:59:02 +02:00
Aiirondev_dev cd199f105d Test data
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:46:00 +02:00
Aiirondev_dev f77f7024dd Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-23 20:31:20 +02:00
Aiirondev_dev 5202c5e25e Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:19:21 +02:00
Aiirondev_dev 2b1b01f4c1 Fix of an dispalying issue
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:16:44 +02:00
Aiirondev_dev 55c21ac851 Test data error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:57:05 +02:00
Aiirondev_dev 8069c6011c Change of a naming error
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:54:42 +02:00
Aiirondev_dev 4535800a34 change of the datetime implementation
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:38:08 +02:00
Aiirondev_dev e3aea734bb Change of the notification-reminder
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:36:06 +02:00
Aiirondev_dev ad77514574 Implementation for the test data
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:29:54 +02:00
Aiirondev_dev 9870726c76 changes to the testdataset used to test the system
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 19:14:48 +02:00
Aiirondev_dev 683059f3de changes to the mahnungssystem in generell and especialy for the overview
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:05:59 +02:00
Aiirondev_dev ffab9b7fdf changes to the mahnungen_admin
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 17:42:12 +02:00
Aiirondev_dev 2f2c71d0a4 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:28:43 +02:00
Aiirondev_dev 5388562b46 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:26:35 +02:00
Aiirondev_dev df9367a19d implementation of the right time zones and blocked user by mahnung as a blocker for the library ausleih process
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 12:26:36 +02:00
Aiirondev_dev 0f0f0ebefd impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:29:42 +02:00
Aiirondev_dev 65fadf7f5f impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:28:53 +02:00
Aiirondev_dev 6f9994c4ce Addtion of missing import
Release Inventarsystem / release-docker (push) Successful in 3m9s
2026-08-23 10:27:36 +02:00
10 changed files with 1096 additions and 541 deletions
+733 -411
View File
File diff suppressed because it is too large Load Diff
+19 -14
View File
@@ -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
+13 -12
View File
@@ -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': ""}
}
@@ -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()
+5 -4
View File
@@ -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"))
}
}
)
+81
View File
@@ -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)
+9 -3
View File
@@ -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()
)
+5 -4
View File
@@ -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,
+170 -83
View File
@@ -129,97 +129,184 @@
}
</style>
<div class="mahnungen-shell">
<div class="mahnungen-head">
<h1>Mahnungsübersicht</h1>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<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>
</div>
<div class="mahnungen-card">
{% if overdue_list %}
<div class="search-box-wrapper">
<input type="text" id="searchInput" class="search-box" placeholder="🔍 Suche nach Nutzer, Klasse oder Gegenstand..." onkeyup="filterTable()">
</div>
<table class="mahnungen-table" id="mahnungTable">
<thead>
<tr>
<th>Nutzer</th>
<th>Klasse</th>
<th>Gegenstand</th>
<th>Fällig am</th>
<th>Tage drüber</th>
<th>Mahnstufe</th>
<th>Konto-Status</th>
</tr>
</thead>
<tbody>
{% for item in overdue_list %}
<tr>
<td><strong>{{ item.user or '—' }}</strong></td>
<td>{{ item.klasse or '—' }}</td>
<td>{{ item.item_name or '—' }}</td>
<td><span class="mono">{{ item.due_date or '—' }}</span></td>
<td class="text-danger">{{ item.days_overdue }} Tage</td>
<td>
{% if item.mahnstufe == 2 %}
<span class="badge-pill badge-open">Stufe 2 (Gesperrt)</span>
{% elif item.mahnstufe == 1 %}
<span class="badge-pill badge-warning">Stufe 1 (Gewarnt)</span>
{% else %}
<span class="badge-pill badge-info">Stufe 0 (Neu)</span>
{% endif %}
</td>
<td>
{% if item.is_blocked %}
<span class="badge-pill badge-open">Gesperrt</span>
{% else %}
<span class="badge-pill badge-paid">Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<strong>Hervorragend!</strong> Aktuell gibt es keine überfälligen Ausleihen im System.
</div>
{% endif %}
</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 filterTable() {
const input = document.getElementById("searchInput");
const filter = input.value.toLowerCase();
const table = document.getElementById("mahnungTable");
const tr = table.getElementsByTagName("tbody")[0].getElementsByTagName("tr");
function openEmailModal(loanId, studentName, studentEmail) {
document.getElementById('modalLoanId').value = loanId;
document.getElementById('modalStudentName').value = studentName;
document.getElementById('modalEmail').value = studentEmail || '';
document.getElementById('modalMessage').value = '';
for (let i = 0; i < tr.length; i++) {
const tdUser = tr[i].getElementsByTagName("td")[0];
const tdClass = tr[i].getElementsByTagName("td")[1];
const tdItem = tr[i].getElementsByTagName("td")[2];
var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
myModal.show();
}
if (tdUser || tdClass || tdItem) {
const textUser = tdUser.textContent || tdUser.innerText;
const textClass = tdClass.textContent || tdClass.innerText;
const textItem = tdItem.textContent || tdItem.innerText;
function submitEmailMahnung() {
const loanId = document.getElementById('modalLoanId').value;
const email = document.getElementById('modalEmail').value;
const message = document.getElementById('modalMessage').value;
if (
textUser.toLowerCase().indexOf(filter) > -1 ||
textClass.toLowerCase().indexOf(filter) > -1 ||
textItem.toLowerCase().indexOf(filter) > -1
) {
tr[i].style.display = "";
} else {
tr[i].style.display = "none";
}
}
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 %}
+58 -8
View File
@@ -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>
@@ -87,14 +90,14 @@
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>
@@ -313,6 +316,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 hinzufügen
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 +383,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 +396,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
View File
@@ -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()):