Compare commits

..

13 Commits

Author SHA1 Message Date
Aiirondev_dev 86d8f19313 Implement notification system with user and admin views, including unread count and reminder functionality for damaged items 2026-04-13 20:29:50 +02:00
Maximilian G. e74798f252 Update support status for version 3.0.x 2026-04-13 12:10:03 +02:00
Aiirondev_dev 31c3d700a8 Update terminology from "Schülerausweise" to "Bibliotheksausweis" across admin and print templates for consistency 2026-04-12 21:31:11 +02:00
Aiirondev_dev a3c114e4b5 Refactor bulk delete functionality to toggle visibility of checkboxes in admin view 2026-04-12 21:21:50 +02:00
Aiirondev_dev 170a2a53ab Reduce Gunicorn worker count and adjust timeout settings for optimized performance 2026-04-12 20:57:41 +02:00
Aiirondev_dev a8ac08d5b0 Implement light mode for item loading to optimize initial data fetch 2026-04-12 20:36:13 +02:00
Aiirondev_dev 05c4c73635 Add loading indicator for main items in admin view to enhance user experience 2026-04-12 20:21:47 +02:00
Aiirondev_dev 76dd00831e Enhance item count display logic in admin view for improved accuracy 2026-04-12 20:02:19 +02:00
Aiirondev_dev 205a56ff24 Update filter display function calls for improved clarity in UI updates 2026-04-12 19:47:56 +02:00
Aiirondev_dev 60ec21f34e Implement lazy loading and prefetching for main admin items to enhance performance 2026-04-12 19:30:20 +02:00
Aiirondev_dev 57f6d2f8e7 Refactor MongoClient function to enhance configuration handling and preserve caller arguments 2026-04-12 19:18:30 +02:00
Aiirondev_dev 53575c1876 Refactor MongoDB client initialization for improved performance and simplicity 2026-04-12 17:50:24 +02:00
Aiirondev_dev 0f372f5056 Refactor MongoDB client imports and implement lazy loading for main items in UI 2026-04-12 17:36:49 +02:00
14 changed files with 1148 additions and 51 deletions
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+2 -1
View File
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
| Version | Supported |
| ------- | ------------------ |
| 0.2.17 | ✅ |
| 3.2.x | :white_check_mark: |
| 3.1.x | :x: |
| 3.0.x | :white_check_mark: |
| 3.0.x | :x: |
| 2.6.x | :x: |
| 2.4.x | :x: |
| 1.8.x | :x: |
+486 -11
View File
@@ -33,7 +33,6 @@ import ausleihung as au
import audit_log as al
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from pymongo import MongoClient
from bson.objectid import ObjectId
from urllib.parse import urlparse, urlunparse
import requests
@@ -59,6 +58,7 @@ import subprocess
# Set base directory and centralized settings
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import settings as cfg
from settings import MongoClient
app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -436,17 +436,183 @@ def _prepare_invoice_pdf_payload(invoice_data, borrow_doc=None, item_doc=None):
'amount_text': amount_text or '-',
}
def _create_notification(db, *, audience, notif_type, title, message, target_user=None, reference=None, unique_key=None, severity='info'):
"""Create a notification entry with optional deduplication via unique_key."""
notifications_col = db['notifications']
if unique_key:
existing = notifications_col.find_one({'UniqueKey': unique_key}, {'_id': 1})
if existing:
return False
now = datetime.datetime.now()
payload = {
'Audience': audience,
'Type': notif_type,
'Title': title,
'Message': message,
'TargetUser': target_user,
'Reference': reference or {},
'UniqueKey': unique_key,
'Severity': severity,
'ReadBy': [],
'CreatedAt': now,
'UpdatedAt': now,
}
notifications_col.insert_one(payload)
return True
def _get_notifications_for_user(db, username, is_admin=False, limit=150):
"""Fetch notifications visible to the current user, newest first."""
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin:
query['$or'].append({'Audience': 'admin'})
cursor = db['notifications'].find(query).sort('CreatedAt', -1).limit(limit)
return list(cursor)
def _get_unread_notification_count(db, username, is_admin=False):
"""Count unread notifications for navbar badge rendering."""
query = {
'$and': [
{
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
},
{'ReadBy': {'$ne': username}},
]
}
if is_admin:
query['$and'][0]['$or'].append({'Audience': 'admin'})
return db['notifications'].count_documents(query)
def _build_reminder_message(item_name, start_dt=None, end_dt=None):
"""Build a concise reminder text for borrowed items."""
start_text = start_dt.strftime('%d.%m.%Y %H:%M') if isinstance(start_dt, datetime.datetime) else '-'
end_text = end_dt.strftime('%d.%m.%Y %H:%M') if isinstance(end_dt, datetime.datetime) else '-'
return (
f"Bitte denke an die Rueckgabe von '{item_name}'. "
f"Ausleihe seit: {start_text}. "
f"Geplantes Ende: {end_text}."
)
def create_return_reminders():
"""Create one-time reminders for day-1 and planned-end events."""
now = datetime.datetime.now()
one_day_ago = now - datetime.timedelta(days=1)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
candidates = list(ausleihungen_col.find(
{
'Status': {'$in': ['active', 'planned']},
'User': {'$exists': True, '$ne': ''},
'$or': [
{'Start': {'$lte': one_day_ago}},
{'End': {'$lte': now}},
]
},
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1}
))
item_ids = []
for entry in candidates:
item_id = entry.get('Item')
if item_id:
item_ids.append(item_id)
item_docs = {}
for raw_id in item_ids:
try:
doc = items_col.find_one({'_id': ObjectId(raw_id)}, {'Name': 1, 'Code_4': 1})
if doc:
item_docs[raw_id] = doc
except Exception:
continue
for borrow_doc in candidates:
borrow_id = str(borrow_doc.get('_id'))
username = str(borrow_doc.get('User', '')).strip()
if not borrow_id or not username:
continue
item_id = str(borrow_doc.get('Item', '')).strip()
item_doc = item_docs.get(item_id, {})
item_name = item_doc.get('Name') or f'Item {item_id}'
start_dt = borrow_doc.get('Start')
end_dt = borrow_doc.get('End')
if isinstance(start_dt, datetime.datetime) and start_dt <= one_day_ago:
_create_notification(
db,
audience='user',
notif_type='return_day_1',
title='Erinnerung: Rueckgabe nach 1 Tag',
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
target_user=username,
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'day_1'},
unique_key=f'reminder:day1:{borrow_id}',
severity='warning',
)
if isinstance(end_dt, datetime.datetime) and end_dt <= now:
_create_notification(
db,
audience='user',
notif_type='return_after_end',
title='Erinnerung: Geplante Ausleihe ist beendet',
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
target_user=username,
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'after_end'},
unique_key=f'reminder:end:{borrow_id}',
severity='warning',
)
except Exception as exc:
app.logger.warning(f"Reminder creation failed: {exc}")
finally:
if client:
client.close()
@app.context_processor
def inject_version():
"""Inject global template variables."""
is_admin = False
asset_version = _get_asset_version()
unread_notification_count = 0
if 'username' in session:
try:
is_admin = us.check_admin(session['username'])
except Exception:
is_admin = False
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
unread_notification_count = _get_unread_notification_count(db, session['username'], is_admin=is_admin)
except Exception:
unread_notification_count = 0
finally:
if client:
client.close()
current_module = _get_current_module(request.path)
return {
@@ -456,7 +622,8 @@ def inject_version():
'school_periods': SCHOOL_PERIODS,
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED,
'is_admin': is_admin
'is_admin': is_admin,
'unread_notification_count': unread_notification_count,
}
# Create necessary directories at startup
@@ -639,6 +806,7 @@ scheduler = BackgroundScheduler()
if cfg.SCHEDULER_ENABLED:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.start()
# Register shutdown handler to stop scheduler when app is terminated
@@ -2282,7 +2450,7 @@ def library_admin():
@app.route('/student_cards_admin', methods=['GET', 'POST'])
def student_cards_admin():
"""
Admin page for managing student library cards (Schülerausweise).
Admin page for managing student library cards (Bibliotheksausweis).
Only accessible by admins and only when the student cards module is enabled.
"""
if 'username' not in session:
@@ -2410,7 +2578,7 @@ def student_cards_admin():
@app.route('/student_cards_print', methods=['GET'])
def student_cards_print():
"""
Generate a printable template for all student library cards (Schülerausweise).
Generate a printable template for all student library cards (Bibliotheksausweis).
Only accessible by admins and only when the student cards module is enabled.
"""
if 'username' not in session:
@@ -2950,6 +3118,7 @@ def get_items():
available_only = str(request.args.get('available_only', '')).strip().lower() in ('1', 'true', 'yes', 'on')
offset_raw = request.args.get('offset')
limit_raw = request.args.get('limit')
light_mode_param = str(request.args.get('light_mode', '')).strip().lower()
pagination_requested = offset_raw is not None or limit_raw is not None
if pagination_requested:
@@ -2966,6 +3135,13 @@ def get_items():
offset = 0
limit = None
if light_mode_param in ('1', 'true', 'yes', 'on'):
light_mode = True
elif light_mode_param in ('0', 'false', 'no', 'off'):
light_mode = False
else:
light_mode = (offset == 0)
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
@@ -2977,18 +3153,97 @@ def get_items():
total_count = items_col.count_documents(base_query)
items_cur = items_col.find(base_query).sort([('Name', 1), ('_id', 1)])
light_projection = {
'Name': 1,
'Code_4': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Verfuegbar': 1,
'Filter': 1,
'Filter2': 1,
'Filter3': 1,
'Ort': 1,
'User': 1,
'ItemType': 1,
}
# Full projection: all details for detailed view
full_projection = {
'Name': 1,
'Ort': 1,
'Beschreibung': 1,
'Filter': 1,
'Filter2': 1,
'Filter3': 1,
'Code_4': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Verfuegbar': 1,
'User': 1,
'BorrowerInfo': 1,
'appointments': 1,
'BlockedNow': 1,
'Reservierbar': 1,
'DamageReports': 1,
'ISBN': 1,
'Author': 1,
'Autor': 1,
'Anschaffungsjahr': 1,
'Anschaffungskosten': 1,
'Condition': 1,
'HasDamage': 1,
'ItemType': 1,
'SeriesGroupId': 1,
'LastUpdated': 1,
}
parent_projection = light_projection if light_mode else full_projection
items_cur = items_col.find(base_query, parent_projection).sort([('Name', 1), ('_id', 1)])
if pagination_requested:
items_cur = items_cur.skip(offset).limit(limit)
items = []
for itm in items_cur:
item_id_str = str(itm['_id'])
grouped_children = list(items_col.find({
'ParentItemId': item_id_str,
parent_items = list(items_cur)
parent_ids = [str(item.get('_id')) for item in parent_items if item.get('_id') is not None]
children_by_parent = {}
if parent_ids:
# Light mode: minimal child data for counting only
light_child_projection = {
'_id': 1,
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'Name': 1,
}
# Full mode: complete child data with all details
full_child_projection = {
'_id': 1,
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'Name': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Beschreibung': 1,
}
child_projection = light_child_projection if light_mode else full_child_projection
child_cursor = items_col.find({
'ParentItemId': {'$in': parent_ids},
'IsGroupedSubItem': True,
'Deleted': {'$ne': True},
}))
}, child_projection)
for child in child_cursor:
parent_id = str(child.get('ParentItemId') or '')
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(child)
items = []
for itm in parent_items:
item_id_str = str(itm['_id'])
grouped_children = children_by_parent.get(item_id_str, [])
grouped_count = 1 + len(grouped_children)
grouped_units = [itm] + grouped_children
@@ -3028,6 +3283,7 @@ def get_items():
'limit': limit if limit is not None else count,
'count': count,
'total': total_count,
'light_mode': light_mode,
'has_more': pagination_requested and ((offset + count) < total_count)
})
except Exception as e:
@@ -4587,6 +4843,19 @@ def report_damage(id):
updated_item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1})
damage_count = len(updated_item.get('DamageReports', [])) if updated_item else 0
_create_notification(
db,
audience='admin',
notif_type='damage_reported',
title='Defekt gemeldet',
message=(
f"Fuer das Item '{item_doc.get('Name', id)}' wurde ein Defekt gemeldet. "
f"Meldung von {session.get('username', '-')}: {description}"
),
reference={'item_id': id, 'damage_count': damage_count},
severity='danger',
)
# Best-effort system log entry for auditability
try:
logs_collection = db['system_logs']
@@ -7484,6 +7753,212 @@ def my_borrowed_items():
user_is_admin=user_is_admin
)
@app.route('/notifications')
def notifications_view():
"""Notification center for users and admins."""
if 'username' not in session:
flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error')
return redirect(url_for('login'))
username = session['username']
is_admin_user = False
try:
is_admin_user = us.check_admin(username)
except Exception:
is_admin_user = False
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
notifications = _get_notifications_for_user(db, username, is_admin=is_admin_user, limit=200)
user_notifications = []
admin_notifications = []
for notif in notifications:
is_read = username in (notif.get('ReadBy') or [])
row = {
'id': str(notif.get('_id')),
'title': notif.get('Title', 'Benachrichtigung'),
'message': notif.get('Message', ''),
'severity': notif.get('Severity', 'info'),
'type': notif.get('Type', ''),
'created_at': notif.get('CreatedAt'),
'is_read': is_read,
'reference': notif.get('Reference', {}) or {},
}
if notif.get('Audience') == 'admin':
admin_notifications.append(row)
else:
user_notifications.append(row)
return render_template(
'notifications.html',
user_notifications=user_notifications,
admin_notifications=admin_notifications,
is_admin_user=is_admin_user,
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
)
except Exception as exc:
app.logger.error(f"Error loading notifications: {exc}")
flash('Fehler beim Laden der Benachrichtigungen.', 'error')
return redirect(url_for('home'))
finally:
if client:
client.close()
@app.route('/notifications/mark_read/<notification_id>', methods=['POST'])
def mark_notification_read(notification_id):
"""Mark a single notification as read for the current user."""
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_one(
{'_id': ObjectId(notification_id)},
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
except Exception as exc:
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
finally:
if client:
client.close()
return redirect(url_for('notifications_view'))
@app.route('/notifications/mark_all_read', methods=['POST'])
def mark_all_notifications_read():
"""Mark all visible notifications as read for the current user."""
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
is_admin_user = False
try:
is_admin_user = us.check_admin(username)
except Exception:
is_admin_user = False
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin_user:
query['$or'].append({'Audience': 'admin'})
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_many(
query,
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
finally:
if client:
client.close()
return redirect(url_for('notifications_view'))
@app.route('/admin/damaged_items')
def admin_damaged_items():
"""Dedicated admin management window for damaged items."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
items = list(items_col.find(
{
'Deleted': {'$ne': True},
'$or': [
{'HasDamage': True},
{'Condition': 'destroyed'},
{'DamageReports.0': {'$exists': True}},
]
},
{
'Name': 1,
'Code_4': 1,
'ItemType': 1,
'Author': 1,
'ISBN': 1,
'Condition': 1,
'DamageReports': 1,
'DamageRepairs': 1,
'Verfuegbar': 1,
'User': 1,
'LastUpdated': 1,
}
).sort('LastUpdated', -1))
damaged_rows = []
for item_doc in items:
item_id = str(item_doc.get('_id'))
active_borrow = ausleihungen_col.find_one(
{'Item': item_id, 'Status': {'$in': ['active', 'planned']}},
{'_id': 1, 'User': 1, 'Status': 1, 'End': 1}
)
reports = item_doc.get('DamageReports', []) or []
latest_report = reports[0] if reports else {}
damaged_rows.append({
'id': item_id,
'name': item_doc.get('Name', ''),
'code': item_doc.get('Code_4', ''),
'item_type': item_doc.get('ItemType', ''),
'author': item_doc.get('Author', ''),
'isbn': item_doc.get('ISBN', ''),
'condition': item_doc.get('Condition', ''),
'available': bool(item_doc.get('Verfuegbar', False)),
'borrow_user': item_doc.get('User', ''),
'damage_count': len(reports),
'damage_reports': reports,
'latest_damage_description': latest_report.get('description', ''),
'latest_damage_by': latest_report.get('reported_by', ''),
'latest_damage_at': latest_report.get('reported_at'),
'active_borrow': active_borrow,
'last_updated': item_doc.get('LastUpdated'),
})
return render_template(
'admin_damaged_items.html',
damaged_items=damaged_rows,
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
)
except Exception as exc:
app.logger.error(f"Error loading damaged-items admin view: {exc}")
flash('Fehler beim Laden der Defekte-Items-Verwaltung.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@app.route('/favicon.ico')
def favicon():
"""
+1 -1
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import pytz
@@ -34,6 +33,7 @@ import os
import json
import shutil
import settings as cfg
from settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
+1 -1
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
+19
View File
@@ -12,6 +12,7 @@ defaults for the web application and helper modules.
"""
import os
import json
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -173,3 +174,21 @@ if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
def MongoClient(*args, **kwargs):
"""Return a lightweight MongoDB client configured from this settings module."""
client_kwargs = {
'maxPoolSize': 10,
'minPoolSize': 0,
'connectTimeoutMS': 5000,
'serverSelectionTimeoutMS': 5000,
'retryWrites': True,
}
client_kwargs.update(kwargs)
# Preserve caller-provided positional host/port arguments.
# If none are passed, use configured defaults.
if args:
return _PyMongoClient(*args, **client_kwargs)
return _PyMongoClient(MONGODB_HOST, MONGODB_PORT, **client_kwargs)
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Defekte Items verwalten{% endblock %}
{% block content %}
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
<div>
<h1 style="margin:0;">Defekte Items</h1>
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
{% if library_module_enabled %}
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
{% endif %}
</div>
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
<table id="damage-table" style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
</tr>
</thead>
<tbody>
{% for item in damaged_items %}
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-weight:700;">{{ item.name }}</div>
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
Gemeldet von {{ item.latest_damage_by or '-' }}
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
{% if item.active_borrow %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
{% if item.active_borrow.End %}
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
{% endif %}
{% else %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not damaged_items %}
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
{% endif %}
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('damage-search');
if (searchInput) {
searchInput.addEventListener('input', function() {
const q = (searchInput.value || '').toLowerCase();
document.querySelectorAll('.damage-row').forEach(function(row) {
const text = row.getAttribute('data-search') || '';
row.style.display = text.includes(q) ? '' : 'none';
});
});
}
})();
function repairDamage(itemId, button) {
if (!itemId) return;
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
return;
}
button.disabled = true;
fetch('/mark_damage_repaired/' + itemId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (!data.success) {
throw new Error(data.message || 'Reparatur fehlgeschlagen');
}
window.location.reload();
})
.catch(function(err) {
button.disabled = false;
alert(err.message || 'Fehler beim Markieren als repariert.');
});
}
</script>
{% endblock %}
+35 -1
View File
@@ -410,6 +410,14 @@
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
<li>
<a class="dropdown-item" href="{{ url_for('notifications_view') }}">
Benachrichtigungen
{% if unread_notification_count and unread_notification_count > 0 %}
<span class="notif-badge">{{ unread_notification_count }}</span>
{% endif %}
</a>
</li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
@@ -418,6 +426,7 @@
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
@@ -476,14 +485,23 @@
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
<li>
<a class="dropdown-item" href="{{ url_for('notifications_view') }}">
Benachrichtigungen
{% if unread_notification_count and unread_notification_count > 0 %}
<span class="notif-badge">{{ unread_notification_count }}</span>
{% endif %}
</a>
</li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
@@ -597,6 +615,22 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
.notif-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 0.72rem;
font-weight: 800;
padding: 0 6px;
margin-left: 8px;
line-height: 1;
}
</style>
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
<div class="cb-inner">
+192 -11
View File
@@ -725,9 +725,74 @@
let allItems = [];
const MAIN_ITEMS_PAGE_SIZE = 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
return itemsContainer;
}
function updateMainItemsLoadingIndicator() {
const itemsContainer = ensureMainItemsLoadingIndicator();
if (!itemsContainer || !mainItemsLoadingIndicator) return;
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}`)
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -1009,7 +1074,7 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z)
// Start display from leftmost position on full reload only.
if (!append) {
itemsContainer.scrollLeft = 0;
}
@@ -1028,18 +1093,17 @@
rebuildFilter3Options();
}
if (data.has_more) {
const nextOffset = (data.offset || offset) + (data.count || pageItems.length);
const continueLoad = () => loadItems(nextOffset, true);
if ('requestIdleCallback' in window) {
requestIdleCallback(continueLoad, { timeout: 250 });
} else {
setTimeout(continueLoad, 0);
}
}
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
updateMainItemsLoadingIndicator();
setupMainItemsLazyLoading();
maybePrefetchMainItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainItemsLoadingMore = false;
mainItemsHasMore = false;
updateMainItemsLoadingIndicator();
if (append) {
return;
}
@@ -1049,6 +1113,72 @@
});
}
function ensureMainItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainItemsLoadingIndicator();
if (!mainItemsSentinel) {
mainItemsSentinel = document.createElement('div');
mainItemsSentinel.id = 'main-items-sentinel';
mainItemsSentinel.style.flex = '0 0 1px';
mainItemsSentinel.style.width = '1px';
mainItemsSentinel.style.minWidth = '1px';
mainItemsSentinel.style.height = '1px';
mainItemsSentinel.style.pointerEvents = 'none';
}
if (!mainItemsSentinel.parentNode) {
itemsContainer.appendChild(mainItemsSentinel);
} else {
itemsContainer.appendChild(mainItemsSentinel);
}
return itemsContainer;
}
function setupMainItemsLazyLoading() {
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
}
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainItemsLoadingMore || !mainItemsHasMore) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
updateMainItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -2766,6 +2896,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
+203 -18
View File
@@ -323,6 +323,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
@@ -364,13 +415,17 @@
}
.bulk-delete-row {
display: flex;
display: none;
justify-content: flex-start;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bulk-delete-mode-active .bulk-delete-row {
display: flex;
}
.bulk-delete-toggle {
display: inline-flex;
align-items: center;
@@ -2966,9 +3021,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3;
// Update UI to show selected filters
updateSelectedFilters(1);
updateSelectedFilters(2);
updateSelectedFilters(3);
updateSelectedFiltersDisplay(1);
updateSelectedFiltersDisplay(2);
updateSelectedFiltersDisplay(3);
// We'll rebuild Filter 3 options after items are loaded
} catch (e) {
@@ -3005,6 +3060,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
bulkDeleteDrawerOpen = Boolean(open);
const drawer = document.getElementById('bulk-delete-drawer');
const toggle = document.getElementById('bulk-delete-drawer-toggle');
const itemsContainer = document.querySelector('#items-container');
if (drawer) {
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
@@ -3014,6 +3070,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
}
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
if (itemsContainer) {
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
}
}
function toggleBulkDeleteDrawer() {
@@ -3256,9 +3316,75 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Function to load items from server
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
let mainAdminLightMode = true; // Track if we're in light mode to gradually request full data
let mainAdminItemsObserver = null;
let mainAdminItemsSentinel = null;
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
return itemsContainer;
}
function updateMainAdminItemsLoadingIndicator() {
const itemsContainer = ensureMainAdminItemsLoadingIndicator();
if (!itemsContainer || !mainAdminItemsLoadingIndicator) return;
const shouldShow = mainAdminItemsHasMore || mainAdminItemsLoadingMore;
mainAdminItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainAdminItemsLoadingIndicator.classList.toggle('is-loading', mainAdminItemsLoadingMore);
const label = mainAdminItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainAdminItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainAdminItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}`)
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3270,6 +3396,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter3Values = new Set();
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
totalItemsInSystem = Number(data.total || totalItemsInSystem || pageItems.length || 0);
if (!append) {
itemsContainer.innerHTML = '';
}
@@ -3286,9 +3413,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
if (itemsIndicator) {
const shownCount = allItems.length;
const totalCount = Number(data.total || shownCount);
itemsIndicator.textContent = `Objekte im System: ${shownCount}${data.has_more ? ` / ${totalCount} (lädt...)` : ''}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem}${data.has_more ? ' (lädt...)' : ''}`;
}
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
@@ -3556,18 +3681,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
/* favorites render count removed */
if (data.has_more) {
const nextOffset = (data.offset || offset) + (data.count || pageItems.length);
const continueLoad = () => loadItems(nextOffset, true);
if ('requestIdleCallback' in window) {
requestIdleCallback(continueLoad, { timeout: 250 });
} else {
setTimeout(continueLoad, 0);
}
}
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainAdminItemsHasMore = Boolean(data.has_more);
updateMainAdminItemsLoadingIndicator();
setupMainAdminItemsLazyLoading();
maybePrefetchMainAdminItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainAdminItemsLoadingMore = false;
mainAdminItemsHasMore = false;
updateMainAdminItemsLoadingIndicator();
if (append) {
return;
}
@@ -3581,6 +3705,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function ensureMainAdminItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainAdminItemsLoadingIndicator();
if (!mainAdminItemsSentinel) {
mainAdminItemsSentinel = document.createElement('div');
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
mainAdminItemsSentinel.style.flex = '0 0 1px';
mainAdminItemsSentinel.style.width = '1px';
mainAdminItemsSentinel.style.minWidth = '1px';
mainAdminItemsSentinel.style.height = '1px';
mainAdminItemsSentinel.style.pointerEvents = 'none';
}
itemsContainer.appendChild(mainAdminItemsSentinel);
return itemsContainer;
}
function setupMainAdminItemsLazyLoading() {
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
}
if (!mainAdminItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
mainAdminItemsScrollPrefetchBound = true;
}
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
updateMainAdminItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -3675,7 +3860,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${allItems.length} | Angezeigt: ${visibleCount}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem || allItems.length} | Angezeigt: ${visibleCount}`;
}
if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) {
+81
View File
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Benachrichtigungen{% endblock %}
{% block content %}
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
<div>
<h1 style="margin:0;">Benachrichtigungen</h1>
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
</div>
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
</form>
</div>
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
{% if user_notifications %}
<div style="display:grid; gap:10px;">
{% for n in user_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% if is_admin_user %}
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
{% if admin_notifications %}
<div style="display:grid; gap:10px;">
{% for n in admin_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -124,12 +124,12 @@
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box">
<strong>📌 Was wird heruntergeladen?</strong>
<p>Eine druckfertige PDF mit:</p>
<p>✓ Alle Schülerausweise</p>
<p>✓ Alle Bibliotheksausweise</p>
<p>✓ Scanbare CODE128 Barcodes</p>
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
<p>✓ Professionelle Qualität</p>
+3 -3
View File
@@ -8,7 +8,7 @@
-->
{% extends "base.html" %}
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
{% block content %}
<style>
@@ -229,7 +229,7 @@
<div class="container">
<div class="student-card-header">
<div>
<h1>📚 Schülerausweise (Bibliotek)</h1>
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
</div>
<div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
@@ -342,7 +342,7 @@
</table>
{% else %}
<div class="empty-state">
<p>Keine Schülerausweise registriert.</p>
<p>Keine Bibliotheksausweise registriert.</p>
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
</div>
{% endif %}
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id):