diff --git a/Web/app.py b/Web/app.py index d476543..49a0cdd 100755 --- a/Web/app.py +++ b/Web/app.py @@ -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', diff --git a/Web/modules/database/ausleihung.py b/Web/modules/database/ausleihung.py index 5cbc7bd..df60065 100755 --- a/Web/modules/database/ausleihung.py +++ b/Web/modules/database/ausleihung.py @@ -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 diff --git a/Web/modules/database/items.py b/Web/modules/database/items.py index 4f27630..42a13d1 100755 --- a/Web/modules/database/items.py +++ b/Web/modules/database/items.py @@ -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() diff --git a/Web/modules/database/termine.py b/Web/modules/database/termine.py index 145f912..cd7c543 100644 --- a/Web/modules/database/termine.py +++ b/Web/modules/database/termine.py @@ -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")) } } ) diff --git a/Web/modules/database/user.py b/Web/modules/database/user.py index 2ec235a..8f8448c 100755 --- a/Web/modules/database/user.py +++ b/Web/modules/database/user.py @@ -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) diff --git a/Web/push_notifications.py b/Web/push_notifications.py index 6e3fa1e..f28d8df 100644 --- a/Web/push_notifications.py +++ b/Web/push_notifications.py @@ -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, diff --git a/Web/tenant.py b/Web/tenant.py index 066828e..1bf4372 100644 --- a/Web/tenant.py +++ b/Web/tenant.py @@ -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()):