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

This commit is contained in:
2026-08-23 12:26:36 +02:00
parent 0f0f0ebefd
commit df9367a19d
7 changed files with 206 additions and 141 deletions
+94 -118
View File
@@ -228,96 +228,6 @@ def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_lab
client.close()
@app.route('/api/library_return_by_code', methods=['POST'])
def api_library_return_by_code():
"""
Return a library item by scanning its code only (no student card required).
This marks active ausleihungen for the item as completed and updates item status.
"""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
if not cfg.MODULES.is_enabled('library'):
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
payload = request.get_json(silent=True) or {}
item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip()
if not item_code_raw:
return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400
normalized_isbn = normalize_and_validate_isbn(item_code_raw)
normalized_code = item_code_raw.upper()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
query_or = [
{'Code_4': item_code_raw},
{'Code_4': normalized_code},
]
if normalized_isbn:
query_or.append({'ISBN': normalized_isbn})
item_doc = items_col.find_one({
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
'$or': query_or
})
if not item_doc:
return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404
item_id = str(item_doc['_id'])
now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
# If item already available -> nothing to return
if item_doc.get('Verfuegbar', True):
return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409
# Mark active ausleihungen as completed
update_result = ausleihungen_col.update_many(
{'Item': item_id, 'Status': 'active'},
{'$set': {
'Status': 'completed',
'End': now,
'LastUpdated': now
}}
)
# Update item status to available
borrower_name = str(item_doc.get('User') or '').strip() or ''
it.update_item_status(item_id, True, borrower_name)
_append_audit_event_standalone(
event_type='ausleihung_returned_by_code',
payload={
'channel': 'library_return_code',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'completed_records': update_result.modified_count,
'performed_by': session.get('username')
}
)
return jsonify({
'ok': True,
'action': 'returned',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'completed_records': update_result.modified_count,
'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben."
}), 200
except Exception as e:
app.logger.error(f"Error in library return by code: {e}")
return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten der Rückgabe.'}), 500
finally:
if client:
client.close()
# Admin route to trigger rollover manually
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
def admin_trigger_school_year_rollover():
@@ -333,33 +243,6 @@ def admin_trigger_school_year_rollover():
return jsonify({'ok': True, 'summary': summary}), 200
# Schedule annual rollover job using APScheduler (configurable via env)
try:
if cfg.SCHEDULER_ENABLED:
_rollover_month = getattr(cfg, 'SCHOOL_ROLLOVER_MONTH', 9)
_rollover_day = getattr(cfg, 'SCHOOL_ROLLOVER_DAY', 1)
_rollover_hour = getattr(cfg, 'SCHOOL_ROLLOVER_HOUR', 3)
_rollover_minute = getattr(cfg, 'SCHOOL_ROLLOVER_MIN', 0)
_rollover_max_class = getattr(cfg, 'SCHOOL_ROLLOVER_MAX_CLASS', 13)
_rollover_grad_label = getattr(cfg, 'SCHOOL_ROLLOVER_GRADUATE_LABEL', '')
_scheduler = BackgroundScheduler()
# Use a cron-style yearly job on the configured month/day
_scheduler.add_job(
func=lambda: rollover_student_card_classes(dry_run=False, max_class=_rollover_max_class, graduate_label=_rollover_grad_label),
trigger='cron',
month=_rollover_month,
day=_rollover_day,
hour=_rollover_hour,
minute=_rollover_minute,
id='school_year_rollover',
replace_existing=True
)
_scheduler.start()
app.logger.info('Scheduled annual school year rollover: %s-%s %s:%s', _rollover_month, _rollover_day, _rollover_hour, _rollover_minute)
except Exception as e:
app.logger.warning('Failed to schedule school year rollover: %s', e)
# Thumbnail sizes
THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE
PREVIEW_SIZE = cfg.PREVIEW_SIZE
@@ -4291,6 +4174,93 @@ def api_library_group(series_group_id):
app.logger.error('Error loading library group %s: %s', series_group_id, exc)
return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500
@app.route('/api/library_return_by_code', methods=['POST'])
def api_library_return_by_code():
"""
Return a library item by scanning its code only (no student card required).
This marks active ausleihungen for the item as completed and updates item status.
"""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
if not cfg.MODULES.is_enabled('library'):
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
payload = request.get_json(silent=True) or {}
item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip()
if not item_code_raw:
return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400
normalized_isbn = normalize_and_validate_isbn(item_code_raw)
normalized_code = item_code_raw.upper()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
query_or = [
{'Code_4': item_code_raw},
{'Code_4': normalized_code},
]
if normalized_isbn:
query_or.append({'ISBN': normalized_isbn})
item_doc = items_col.find_one({
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
'$or': query_or
})
if not item_doc:
return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404
item_id = str(item_doc['_id'])
now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
# If item already available -> nothing to return
if item_doc.get('Verfuegbar', True):
return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409
# Mark active ausleihungen as completed
update_result = ausleihungen_col.update_many(
{'Item': item_id, 'Status': 'active'},
{'$set': {
'Status': 'completed',
'End': now,
'LastUpdated': now
}}
)
# Update item status to available
borrower_name = str(item_doc.get('User') or '').strip() or ''
it.update_item_status(item_id, True, borrower_name)
_append_audit_event_standalone(
event_type='ausleihung_returned_by_code',
payload={
'channel': 'library_return_code',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'completed_records': update_result.modified_count,
'performed_by': session.get('username')
}
)
return jsonify({
'ok': True,
'action': 'returned',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'completed_records': update_result.modified_count,
'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben."
}), 200
except Exception as e:
app.logger.error(f"Error in library return by code: {e}")
return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten der Rückgabe.'}), 500
finally:
if client:
client.close()
@app.route('/api/library_scan_action', methods=['POST'])
def api_library_scan_action():
@@ -4320,6 +4290,10 @@ def api_library_scan_action():
normalized_isbn = normalize_and_validate_isbn(item_code_raw)
normalized_code = item_code_raw.upper()
if us.student_is_blocked(student_card_id):
flash("User Blockiert!", "error")
return jsonify({'ok': False, 'message': 'User Blockiert.'}), 404
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -4373,8 +4347,10 @@ def api_library_scan_action():
card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS
borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS))
due_date = now + datetime.timedelta(days=borrow_duration_days)
it.update_item_status(item_id, False, borrower_name)
au.add_ausleihung(item_id, borrower_name, now)
au.add_ausleihung(item_id, borrower_name, now, due_date)
_append_audit_event_standalone(
event_type='ausleihung_borrowed',
+5 -1
View File
@@ -34,6 +34,7 @@ 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():
@@ -184,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.
@@ -224,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
+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)
+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,
+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()):