Compare commits

...

15 Commits

Author SHA1 Message Date
Aiirondev_dev fd6915a923 feat: Enhance favorites management by binding session favorites to authenticated users and updating cache handling 2026-04-17 23:25:02 +02:00
Aiirondev_dev 9b7ba39702 feat: Update user registration to include first and last name fields and enhance permission checks for upload actions 2026-04-17 23:22:10 +02:00
Aiirondev_dev 3b637de188 feat: Implement custom permission settings in user registration and update user addition logic 2026-04-17 23:07:37 +02:00
Aiirondev_dev c0f49ab8de feat: Ensure admin users have full access to permissions regardless of presets 2026-04-17 22:59:03 +02:00
Aiirondev_dev c23e128d2e feat: Enhance permission handling in fallback endpoints for improved user experience 2026-04-17 22:54:21 +02:00
Aiirondev_dev b611173ea9 feat: Add admin functionality to anonymize stored names and implement name aliasing 2026-04-17 21:57:49 +02:00
Aiirondev_dev 5cf9a4f1dd feat: Implement user permission management with presets and dynamic access control 2026-04-17 21:14:30 +02:00
Aiirondev_dev 88a67160f2 feat: Refactor mobile navigation styles for improved layout and responsiveness 2026-04-17 20:58:19 +02:00
Aiirondev_dev 2068af106f feat: Enhance mobile navigation layout and styling for improved user experience 2026-04-17 20:30:43 +02:00
Aiirondev_dev 06c2270842 feat: Enhance mobile responsiveness for container and modal styles in main and admin templates 2026-04-17 19:18:46 +02:00
Aiirondev_dev 20556f3500 feat: Simplify scheduled job setup by removing dynamic update timing and buffer management 2026-04-17 19:08:45 +02:00
Aiirondev_dev 7f1d616bb3 feat: Improve mobile layout handling for item loading indicators and prefetching logic 2026-04-17 19:05:34 +02:00
Aiirondev_dev 09cea7a0f8 feat: Implement update scheduling with dynamic timing and update buffer management 2026-04-17 18:58:10 +02:00
Aiirondev_dev 061f975727 feat: Implement Redis caching for notification status and add Redis service to Docker 2026-04-17 18:54:22 +02:00
Aiirondev_dev 68f0efa296 feat: Enhance navbar responsiveness with compact and ultra-compact modes 2026-04-17 18:43:46 +02:00
11 changed files with 1754 additions and 111 deletions
+561 -44
View File
@@ -50,6 +50,11 @@ import io
import html import html
import logging import logging
import secrets import secrets
import importlib
try:
redis = importlib.import_module('redis')
except Exception:
redis = None
# QR Code functionality deactivated # QR Code functionality deactivated
# import qrcode # import qrcode
# from qrcode.constants import ERROR_CORRECT_L # from qrcode.constants import ERROR_CORRECT_L
@@ -129,9 +134,91 @@ SSL_KEY = cfg.SSL_KEY
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media'] LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media']
INVOICE_CURRENCY = 'EUR' INVOICE_CURRENCY = 'EUR'
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
_NOTIFICATION_CACHE_PREFIX = 'inventar:notif'
_NOTIFICATION_REDIS_CLIENT = None
_NOTIFICATION_REDIS_FAILED = False
_NOTIFICATION_LOCAL_CACHE = {}
_NOTIFICATION_LOCAL_VERSIONS = {'admin': 0}
_NOTIFICATION_CACHE_LOCK = threading.Lock()
SCHOOL_PERIODS = cfg.SCHOOL_PERIODS SCHOOL_PERIODS = cfg.SCHOOL_PERIODS
PERMISSION_ACTION_OPTIONS = [
('can_borrow', 'Ausleihe erlauben'),
('can_insert', 'Einfügen/Hochladen erlauben'),
('can_edit', 'Bearbeiten erlauben'),
('can_delete', 'Löschen erlauben'),
('can_manage_users', 'Benutzerverwaltung erlauben'),
('can_manage_settings', 'Systemverwaltung erlauben'),
('can_view_logs', 'Logs/Audit einsehen erlauben'),
]
PERMISSION_PAGE_OPTIONS = [
('home', 'Artikel (Inventar)'),
('tutorial_page', 'Tutorial'),
('my_borrowed_items', 'Meine Ausleihen'),
('notifications_view', 'Benachrichtigungen'),
('impressum', 'Impressum'),
('license', 'Lizenz'),
('library_view', 'Bibliothek (Medien)'),
('terminplan', 'Terminplan'),
('home_admin', 'Admin Startseite'),
('upload_admin', 'Admin Upload Inventar'),
('library_admin', 'Admin Upload Bibliothek'),
('admin_borrowings', 'Admin Ausleihen'),
('library_loans_admin', 'Admin Bibliotheks-Ausleihen'),
('admin_damaged_items', 'Admin Defekte Items'),
('admin_audit_dashboard', 'Admin Audit Dashboard'),
('logs', 'System-Logs'),
('user_del', 'Benutzerverwaltung'),
('register', 'Benutzer anlegen'),
('manage_filters', 'Filter verwalten'),
('manage_locations', 'Orte verwalten'),
]
PERMISSION_EXEMPT_ENDPOINTS = {
'static',
'login',
'logout',
'impressum',
'license',
'uploaded_file',
'thumbnail_file',
'preview_file',
}
PERMISSION_ACTION_ENDPOINTS = {
'upload_item': 'can_insert',
'upload_inventory_excel': 'can_insert',
'upload_library_excel': 'can_insert',
'upload_student_cards_excel': 'can_insert',
'edit_item': 'can_edit',
'api_library_item_update': 'can_edit',
'admin_update_user_name': 'can_edit',
'delete_item': 'can_delete',
'delete_user': 'can_delete',
'ausleihen': 'can_borrow',
'zurueckgeben': 'can_borrow',
'api_library_scan_action': 'can_borrow',
'user_del': 'can_manage_users',
'register': 'can_manage_users',
'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings',
'upload_admin': 'can_insert',
'library_admin': 'can_insert',
'admin_borrowings': 'can_manage_settings',
'library_loans_admin': 'can_manage_settings',
'admin_damaged_items': 'can_manage_settings',
'manage_filters': 'can_manage_settings',
'manage_locations': 'can_manage_settings',
'admin_audit_dashboard': 'can_view_logs',
'logs': 'can_view_logs',
}
# Apply the configuration for general use throughout the app # Apply the configuration for general use throughout the app
APP_VERSION = __version__ APP_VERSION = __version__
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version') RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
@@ -186,6 +273,47 @@ def _enforce_csrf_protection():
return None return None
@app.before_request
def _enforce_user_permissions():
endpoint = request.endpoint
if not endpoint:
return None
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in PERMISSION_EXEMPT_ENDPOINTS:
return None
if 'username' not in session:
return None
permissions = _get_current_user_permissions()
if not permissions:
return None
if not _page_access_allowed(permissions, endpoint):
message = 'Diese Seite ist für Ihren Benutzer aktuell gesperrt.'
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
action_key = PERMISSION_ACTION_ENDPOINTS.get(endpoint)
if action_key and not _action_access_allowed(permissions, action_key):
message = 'Für diese Aktion fehlen Ihnen die erforderlichen Berechtigungen.'
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
return None
def _get_asset_version(): def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state.""" """Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip() env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -224,6 +352,45 @@ def _get_current_module(path):
return 'inventory' return 'inventory'
def _get_current_user_permissions():
username = session.get('username')
if not username:
return None
try:
return us.get_effective_permissions(username)
except Exception:
return us.build_default_permission_payload('standard_user')
def _page_access_allowed(permissions, endpoint):
if not permissions or not endpoint:
return True
page_permissions = permissions.get('pages', {})
return bool(page_permissions.get(endpoint, True))
def _action_access_allowed(permissions, action_key):
if not permissions or not action_key:
return True
action_permissions = permissions.get('actions', {})
return bool(action_permissions.get(action_key, True))
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
username = session.get('username')
is_admin_user = bool(username and us.check_admin(username))
admin_home_allowed = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home'):
if current_endpoint and candidate == current_endpoint:
continue
if candidate == 'home' and is_admin_user and not admin_home_allowed:
continue
if _page_access_allowed(permissions, candidate):
return candidate
return 'logout'
def _append_audit_event(db, event_type, payload): def _append_audit_event(db, event_type, payload):
"""Write an audit entry; never break business flow on audit failures.""" """Write an audit entry; never break business flow on audit failures."""
try: try:
@@ -551,9 +718,154 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
'UpdatedAt': now, 'UpdatedAt': now,
} }
notifications_col.insert_one(payload) notifications_col.insert_one(payload)
if audience == 'user' and target_user:
_bump_notification_version(f'user:{target_user}')
elif audience == 'admin':
_bump_notification_version('admin')
return True return True
def _get_notification_cache_client():
"""Return shared Redis client for distributed notification cache if available."""
global _NOTIFICATION_REDIS_CLIENT, _NOTIFICATION_REDIS_FAILED
if _NOTIFICATION_REDIS_CLIENT is not None:
return _NOTIFICATION_REDIS_CLIENT
if _NOTIFICATION_REDIS_FAILED or redis is None:
return None
try:
redis_host = os.getenv('INVENTAR_REDIS_HOST', 'redis')
redis_port = int(os.getenv('INVENTAR_REDIS_PORT', 6379))
redis_db = int(os.getenv('INVENTAR_REDIS_CACHE_DB', 1))
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True,
socket_keepalive=True,
socket_timeout=1,
socket_connect_timeout=1,
)
client.ping()
_NOTIFICATION_REDIS_CLIENT = client
app.logger.info(f'Notification cache backend enabled: {redis_host}:{redis_port}/db{redis_db}')
return _NOTIFICATION_REDIS_CLIENT
except Exception as exc:
_NOTIFICATION_REDIS_FAILED = True
app.logger.warning(f'Notification cache backend unavailable, using local cache fallback: {exc}')
return None
def _notification_scope_key(scope):
return f'{_NOTIFICATION_CACHE_PREFIX}:ver:{scope}'
def _notification_status_key(username, is_admin, version_tag):
role = 'admin' if is_admin else 'user'
return f'{_NOTIFICATION_CACHE_PREFIX}:status:{role}:{username}:{version_tag}'
def _get_notification_version_tag(username, is_admin=False):
user_scope = f'user:{username}'
cache_client = _get_notification_cache_client()
if cache_client:
user_version = cache_client.get(_notification_scope_key(user_scope)) or '0'
if is_admin:
admin_version = cache_client.get(_notification_scope_key('admin')) or '0'
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
with _NOTIFICATION_CACHE_LOCK:
user_version = _NOTIFICATION_LOCAL_VERSIONS.get(user_scope, 0)
if is_admin:
admin_version = _NOTIFICATION_LOCAL_VERSIONS.get('admin', 0)
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
def _bump_notification_version(scope):
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.incr(_notification_scope_key(scope))
return
except Exception as exc:
app.logger.warning(f'Could not bump notification cache version for {scope}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
current = int(_NOTIFICATION_LOCAL_VERSIONS.get(scope, 0))
_NOTIFICATION_LOCAL_VERSIONS[scope] = current + 1
# Prevent stale local payload reuse after version bump.
_NOTIFICATION_LOCAL_CACHE.clear()
def _get_cached_unread_status(username, is_admin=False):
version_tag = _get_notification_version_tag(username, is_admin=is_admin)
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cached = cache_client.get(key)
if cached:
return json.loads(cached), version_tag
except Exception as exc:
app.logger.warning(f'Could not read notification status cache for {username}: {exc}')
return None, version_tag
now = time.time()
with _NOTIFICATION_CACHE_LOCK:
cached = _NOTIFICATION_LOCAL_CACHE.get(key)
if not cached:
return None, version_tag
if cached.get('expires_at', 0) <= now:
_NOTIFICATION_LOCAL_CACHE.pop(key, None)
return None, version_tag
return cached.get('payload'), version_tag
def _set_cached_unread_status(username, is_admin, version_tag, payload):
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
return
except Exception as exc:
app.logger.warning(f'Could not write notification status cache for {username}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
_NOTIFICATION_LOCAL_CACHE[key] = {
'expires_at': time.time() + NOTIFICATION_STATUS_CACHE_TTL,
'payload': payload,
}
def _build_unread_status_etag(version_tag, payload):
unread_count = int(payload.get('unread_count', 0))
latest = payload.get('latest_unread') or {}
latest_created = latest.get('created_at') or ''
latest_type = latest.get('type') or ''
return f'W/"notif-{version_tag}-{unread_count}-{latest_type}-{latest_created}"'
def _build_cached_json_response(payload, etag_value):
incoming_etag = (request.headers.get('If-None-Match') or '').strip()
if incoming_etag and incoming_etag == etag_value:
response = make_response('', 304)
else:
response = jsonify(payload)
response.headers['Cache-Control'] = f'private, max-age={NOTIFICATION_STATUS_CACHE_TTL}, must-revalidate'
response.headers['ETag'] = etag_value
response.headers['Vary'] = 'Cookie'
return response
def _get_notifications_for_user(db, username, is_admin=False, limit=150): def _get_notifications_for_user(db, username, is_admin=False, limit=150):
"""Fetch notifications visible to the current user, newest first.""" """Fetch notifications visible to the current user, newest first."""
query = { query = {
@@ -687,12 +999,18 @@ def inject_version():
asset_version = _get_asset_version() asset_version = _get_asset_version()
csrf_token = _get_csrf_token() csrf_token = _get_csrf_token()
unread_notification_count = 0 unread_notification_count = 0
current_permissions = us.build_default_permission_payload('standard_user')
if 'username' in session: if 'username' in session:
try: try:
is_admin = us.check_admin(session['username']) is_admin = us.check_admin(session['username'])
except Exception: except Exception:
is_admin = False is_admin = False
try:
current_permissions = us.get_effective_permissions(session['username'])
except Exception:
current_permissions = us.build_default_permission_payload('standard_user')
client = None client = None
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -716,6 +1034,10 @@ def inject_version():
'student_cards_module_enabled': cfg.STUDENT_CARDS_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, 'unread_notification_count': unread_notification_count,
'current_permissions': current_permissions,
'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(),
} }
# Create necessary directories at startup # Create necessary directories at startup
@@ -1275,14 +1597,25 @@ def _student_card_id_slug(value):
return re.sub(r'[^a-z0-9]+', '', normalized).upper() return re.sub(r'[^a-z0-9]+', '', normalized).upper()
def _name_to_alias(full_name):
"""Convert clear names to deterministic aliases, e.g. Simon Frings -> SimFri."""
text = sanitize_form_value(full_name)
if not text:
return 'User'
parts = [p for p in re.split(r'\s+', text) if p]
if len(parts) >= 2:
return us.build_name_synonym(parts[0], parts[-1])
return us.build_name_synonym(parts[0], '')
def _build_student_card_excel_id(student_name, class_name, row_number, used_ids): def _build_student_card_excel_id(student_name, class_name, row_number, used_ids):
"""Create a stable student-card ID when the spreadsheet does not provide one.""" """Create a stable student-card ID without embedding personal names."""
name_slug = _student_card_id_slug(student_name)
class_slug = _student_card_id_slug(class_name) class_slug = _student_card_id_slug(class_name)
base_parts = [part for part in (class_slug, name_slug) if part] base_parts = [part for part in (class_slug,) if part]
if base_parts: if base_parts:
base_id = f"SC-{'-'.join(base_parts[:2])}" base_id = f"SC-{'-'.join(base_parts[:1])}-ROW-{row_number}"
else: else:
base_id = f"SC-ROW-{row_number}" base_id = f"SC-ROW-{row_number}"
@@ -1402,6 +1735,8 @@ def _upload_student_cards_excel():
student_name = f'{first_name} {last_name}'.strip() student_name = f'{first_name} {last_name}'.strip()
validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt')) validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt'))
student_name_alias = _name_to_alias(student_name)
if not ausweis_id and not student_name and not class_name: if not ausweis_id and not student_name and not class_name:
continue continue
@@ -1426,7 +1761,7 @@ def _upload_student_cards_excel():
planned_rows.append({ planned_rows.append({
'row_number': row_number, 'row_number': row_number,
'ausweis_id': ausweis_id, 'ausweis_id': ausweis_id,
'student_name': student_name, 'student_name': student_name_alias,
'class_name': class_name, 'class_name': class_name,
'notes': notes, 'notes': notes,
'default_borrow_days': default_borrow_days, 'default_borrow_days': default_borrow_days,
@@ -1491,8 +1826,9 @@ def _upload_excel_items(scope='inventory'):
flash('Nicht angemeldet.', 'error') flash('Nicht angemeldet.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not us.check_admin(session['username']): permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
flash('Administratorrechte erforderlich.', 'error') if not _action_access_allowed(permissions, 'can_insert'):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home')) return redirect(url_for('home'))
is_library_scope = scope == 'library' is_library_scope = scope == 'library'
@@ -1502,7 +1838,7 @@ def _upload_excel_items(scope='inventory'):
if is_library_scope: if is_library_scope:
if not cfg.LIBRARY_MODULE_ENABLED: if not cfg.LIBRARY_MODULE_ENABLED:
flash('Bibliotheks-Modul ist deaktiviert.', 'error') flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for('home'))
excel_file = request.files.get(file_field) excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename: if not excel_file or not excel_file.filename:
@@ -2080,7 +2416,14 @@ def home():
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
) )
else: else:
return redirect(url_for('home_admin')) permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='home')
if fallback_endpoint == 'logout':
flash('Für diesen Benutzer sind aktuell keine Seiten freigegeben.', 'error')
return redirect(url_for(fallback_endpoint))
@app.route('/home_admin') @app.route('/home_admin')
@@ -2675,8 +3018,8 @@ def api_library_item_update(item_id):
@app.route('/upload_admin') @app.route('/upload_admin')
def upload_admin(): def upload_admin():
""" """
Admin upload page route. Upload page route for inventory items.
Only accessible by users with admin privileges. Accessible to users with insert permission.
Supports duplication by passing duplicate_from parameter. Supports duplication by passing duplicate_from parameter.
Returns: Returns:
@@ -2685,7 +3028,8 @@ def upload_admin():
if 'username' not in session: if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not us.check_admin(session['username']): permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -2749,13 +3093,14 @@ def upload_admin():
@app.route('/library_admin') @app.route('/library_admin')
def library_admin(): def library_admin():
""" """
Dedicated admin page for library/book uploads with ISBN scanning. Dedicated page for library/book uploads with ISBN scanning.
Only accessible by admins and only when the library module is enabled. Accessible to users with insert permission when the library module is enabled.
""" """
if 'username' not in session: if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not us.check_admin(session['username']): permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not cfg.LIBRARY_MODULE_ENABLED: if not cfg.LIBRARY_MODULE_ENABLED:
@@ -2859,6 +3204,7 @@ def student_cards_admin():
action = request.form.get('action', 'add') action = request.form.get('action', 'add')
ausweis_id = request.form.get('ausweis_id', '').strip().upper() ausweis_id = request.form.get('ausweis_id', '').strip().upper()
student_name = request.form.get('student_name', '').strip() student_name = request.form.get('student_name', '').strip()
student_name_alias = _name_to_alias(student_name)
default_borrow_days = request.form.get('default_borrow_days', 14) default_borrow_days = request.form.get('default_borrow_days', 14)
class_name = request.form.get('class_name', '').strip() class_name = request.form.get('class_name', '').strip()
notes = request.form.get('notes', '').strip() notes = request.form.get('notes', '').strip()
@@ -2885,7 +3231,7 @@ def student_cards_admin():
else: else:
encrypted_payload = encrypt_document_fields( encrypted_payload = encrypt_document_fields(
{ {
'SchülerName': student_name, 'SchülerName': student_name_alias,
'Klasse': class_name, 'Klasse': class_name,
'Notizen': notes, 'Notizen': notes,
}, },
@@ -2918,7 +3264,7 @@ def student_cards_admin():
try: try:
encrypted_payload = encrypt_document_fields( encrypted_payload = encrypt_document_fields(
{ {
'SchülerName': student_name, 'SchülerName': student_name_alias,
'Klasse': class_name, 'Klasse': class_name,
'Notizen': notes, 'Notizen': notes,
}, },
@@ -3387,8 +3733,19 @@ def login():
is_admin_user = bool(user.get('Admin', False)) is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user session['admin'] = is_admin_user
session['is_admin'] = is_admin_user session['is_admin'] = is_admin_user
# Bind session favorites to the authenticated user to avoid cross-user leakage.
try:
session['favorites_owner'] = username
session['favorites'] = list(dict.fromkeys([str(f) for f in us.get_favorites(username)]))
except Exception:
session['favorites_owner'] = username
session['favorites'] = []
if is_admin_user: if is_admin_user:
return redirect(url_for('home_admin')) permissions = us.get_effective_permissions(username)
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='login')
return redirect(url_for(fallback_endpoint))
else: else:
return redirect(url_for('home')) return redirect(url_for('home'))
else: else:
@@ -3477,6 +3834,8 @@ def logout():
session.pop('username', None) session.pop('username', None)
session.pop('admin', None) session.pop('admin', None)
session.pop('is_admin', None) session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -3485,6 +3844,7 @@ def get_items():
"""Return items plus merged favorites (session + DB) and per-item favorite flag.""" """Return items plus merged favorites (session + DB) and per-item favorite flag."""
client = None client = None
try: try:
_ensure_session_favs()
username = session.get('username') username = session.get('username')
# Merge DB favorites into session if logged in # Merge DB favorites into session if logged in
if username: if username:
@@ -3731,8 +4091,23 @@ def api_booking_conflicts():
"""Favorites management endpoints (persistent + session cache).""" """Favorites management endpoints (persistent + session cache)."""
def _ensure_session_favs(): def _ensure_session_favs():
if 'favorites' not in session: username = session.get('username')
owner = session.get('favorites_owner')
if not username:
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
return
if owner != username:
session['favorites_owner'] = username
session['favorites'] = [] session['favorites'] = []
session.modified = True
return
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
session.modified = True
@app.route('/favorites', methods=['GET']) @app.route('/favorites', methods=['GET'])
def list_favorites(): def list_favorites():
@@ -3819,6 +4194,7 @@ def toggle_fav(item_id):
@app.route('/debug/favorites') @app.route('/debug/favorites')
def debug_favorites(): def debug_favorites():
"""Diagnostic endpoint: shows session favorites, DB favorites and merged output.""" """Diagnostic endpoint: shows session favorites, DB favorites and merged output."""
_ensure_session_favs()
username = session.get('username') username = session.get('username')
session_favs = list(session.get('favorites', [])) session_favs = list(session.get('favorites', []))
db_favs = [] db_favs = []
@@ -3845,10 +4221,14 @@ def upload_item():
if 'username' not in session: if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
# Check if user is an admin # Check if user may insert items
username = session['username'] username = session['username']
if not us.check_admin(username): permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 if not _action_access_allowed(permissions, 'can_insert'):
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
# Detect if request is from mobile device # Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '') is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
@@ -3930,7 +4310,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error') flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
# Expand special "all values" selections for predefined filters. # Expand special "all values" selections for predefined filters.
filter_upload = expand_filter_selection(filter_upload, 1) filter_upload = expand_filter_selection(filter_upload, 1)
@@ -3943,7 +4323,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
item_isbn = '' item_isbn = ''
item_type = 'general' item_type = 'general'
@@ -3954,7 +4334,7 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
if item_isbn: if item_isbn:
item_type = 'book' item_type = 'book'
@@ -3964,7 +4344,7 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
if not item_isbn: if not item_isbn:
error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.' error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.'
if is_mobile: if is_mobile:
@@ -3981,7 +4361,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
# Check if base code is unique for single-item uploads # Check if base code is unique for single-item uploads
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
@@ -3990,7 +4370,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes # Validate optional per-item codes
if individual_codes: if individual_codes:
@@ -3999,14 +4379,14 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
if len(set(individual_codes)) != len(individual_codes): if len(set(individual_codes)) != len(individual_codes):
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.' error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
for specific_code in individual_codes: for specific_code in individual_codes:
if not it.is_code_unique(specific_code): if not it.is_code_unique(specific_code):
@@ -4014,7 +4394,7 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
def generate_unique_batch_code(base_code, position): def generate_unique_batch_code(base_code, position):
"""Generate a unique code for every item in a batch.""" """Generate a unique code for every item in a batch."""
@@ -4707,7 +5087,7 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
parent_item_id = str(created_item_ids[0]) if created_item_ids else None parent_item_id = str(created_item_ids[0]) if created_item_ids else None
item_id = it.add_item( item_id = it.add_item(
@@ -4734,7 +5114,7 @@ def upload_item():
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 500 return jsonify({'success': False, 'message': error_msg}), 500
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
item_id = created_item_ids[0] if created_item_ids else None item_id = created_item_ids[0] if created_item_ids else None
@@ -4758,14 +5138,14 @@ def upload_item():
}) })
else: else:
flash(success_msg, 'success') flash(success_msg, 'success')
return redirect(url_for('home_admin', highlight_item=str(item_id))) return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
else: else:
error_msg = 'Fehler beim Hinzufügen des Elements' error_msg = 'Fehler beim Hinzufügen des Elements'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 500 return jsonify({'success': False, 'message': error_msg}), 500
else: else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for('home_admin')) return redirect(url_for(success_redirect_endpoint))
@app.route('/duplicate_item', methods=['POST']) @app.route('/duplicate_item', methods=['POST'])
@@ -6341,12 +6721,14 @@ def register():
if request.method == 'POST': if request.method == 'POST':
username = request.form['username'] username = request.form['username']
password = request.form['password'] password = request.form['password']
name = request.form['name'] name = (request.form.get('name') or '').strip()
last_name = request.form['last-name'] last_name = (request.form.get('last-name') or '').strip()
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False
student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else '' student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else ''
max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None
if not username or not password: if not username or not password or not name or not last_name:
flash('Bitte füllen Sie alle Felder aus', 'error') flash('Bitte füllen Sie alle Felder aus', 'error')
return redirect(url_for('register')) return redirect(url_for('register'))
if us.get_user(username): if us.get_user(username):
@@ -6356,6 +6738,17 @@ def register():
flash('Passwort ist zu schwach', 'error') flash('Passwort ist zu schwach', 'error')
return redirect(url_for('register')) return redirect(url_for('register'))
action_permissions = None
page_permissions = None
if use_custom_permissions:
action_permissions = {}
for action_key, _ in PERMISSION_ACTION_OPTIONS:
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
page_permissions = {}
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
max_borrow_days = None max_borrow_days = None
if is_student: if is_student:
if not student_card_id: if not student_card_id:
@@ -6377,7 +6770,10 @@ def register():
last_name, last_name,
is_student=is_student, is_student=is_student,
student_card_id=student_card_id if is_student else None, student_card_id=student_card_id if is_student else None,
max_borrow_days=max_borrow_days max_borrow_days=max_borrow_days,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
) )
return redirect(url_for('home')) return redirect(url_for('home'))
return render_template( return render_template(
@@ -6419,6 +6815,10 @@ def user_del():
break break
if username and username != session['username']: if username and username != session['username']:
try:
permissions_payload = us.get_effective_permissions(username)
except Exception:
permissions_payload = us.build_default_permission_payload('standard_user')
try: try:
name = us.get_name(username) name = us.get_name(username)
last_name = us.get_last_name(username) last_name = us.get_last_name(username)
@@ -6439,7 +6839,10 @@ def user_del():
'admin': user.get('Admin', False), 'admin': user.get('Admin', False),
'fullname': fullname, 'fullname': fullname,
'name': name, 'name': name,
'last_name': last_name 'last_name': last_name,
'permission_preset': permissions_payload.get('preset', 'standard_user'),
'action_permissions': permissions_payload.get('actions', {}),
'page_permissions': permissions_payload.get('pages', {}),
}) })
return render_template( return render_template(
@@ -7456,6 +7859,107 @@ def admin_update_user_name():
return redirect(url_for('user_del')) return redirect(url_for('user_del'))
@app.route('/admin_update_user_permissions', methods=['POST'])
def admin_update_user_permissions():
"""Admin route to update permission preset and per-endpoint overrides for a user."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
username = request.form.get('username', '').strip()
preset_key = request.form.get('permission_preset', 'standard_user').strip()
if not username:
flash('Kein Benutzer ausgewählt', 'error')
return redirect(url_for('user_del'))
target_user = us.get_user(username)
if not target_user:
flash(f'Benutzer {username} nicht gefunden', 'error')
return redirect(url_for('user_del'))
action_permissions = {}
for action_key, _ in PERMISSION_ACTION_OPTIONS:
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
page_permissions = {}
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
if us.update_user_permissions(username, preset_key, action_permissions, page_permissions):
flash(f'Berechtigungen für {username} wurden aktualisiert.', 'success')
else:
flash('Fehler beim Aktualisieren der Berechtigungen.', 'error')
return redirect(url_for('user_del'))
@app.route('/admin_anonymize_names', methods=['POST'])
def admin_anonymize_names():
"""Anonymize already stored personal names into short aliases."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
users_col = db['users']
student_cards_col = db['student_cards']
users_updated = 0
cards_updated = 0
for user_doc in users_col.find({}, {'name': 1, 'last_name': 1, 'Username': 1, 'username': 1}):
first = str(user_doc.get('name') or '').strip()
last = str(user_doc.get('last_name') or '').strip()
fallback = str(user_doc.get('Username') or user_doc.get('username') or '').strip()
alias = us.build_name_synonym(first or fallback, last)
result = users_col.update_one(
{'_id': user_doc['_id']},
{'$set': {'name': alias, 'last_name': ''}}
)
if result.modified_count > 0:
users_updated += 1
for card_doc in student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'Notizen': 1}):
decrypted = _decrypt_student_card_doc(card_doc)
alias = _name_to_alias(decrypted.get('SchülerName', ''))
class_name = sanitize_form_value(decrypted.get('Klasse', ''))
notes = sanitize_form_value(decrypted.get('Notizen', ''))
encrypted_payload = encrypt_document_fields(
{
'SchülerName': alias,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS,
)
result = student_cards_col.update_one(
{'_id': card_doc['_id']},
{'$set': {'Aktualisiert': datetime.datetime.now(), **encrypted_payload}}
)
if result.modified_count > 0:
cards_updated += 1
flash(
f'Anonymisierung abgeschlossen: {users_updated} Benutzer und {cards_updated} Ausweise aktualisiert.',
'success'
)
except Exception as exc:
app.logger.error(f'Error anonymizing names: {exc}')
flash('Fehler bei der Anonymisierung der Namen.', 'error')
finally:
if client:
client.close()
return redirect(url_for('user_del'))
@app.route('/logs') @app.route('/logs')
def logs(): def logs():
""" """
@@ -8284,13 +8788,15 @@ def mark_notification_read(notification_id):
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB] db = client[MONGODB_DB]
db['notifications'].update_one( result = db['notifications'].update_one(
{'_id': ObjectId(notification_id)}, {'_id': ObjectId(notification_id)},
{ {
'$addToSet': {'ReadBy': username}, '$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()} '$set': {'UpdatedAt': datetime.datetime.now()}
} }
) )
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}") app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
finally: finally:
@@ -8325,13 +8831,15 @@ def mark_all_notifications_read():
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB] db = client[MONGODB_DB]
db['notifications'].update_many( result = db['notifications'].update_many(
query, query,
{ {
'$addToSet': {'ReadBy': username}, '$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()} '$set': {'UpdatedAt': datetime.datetime.now()}
} }
) )
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}") app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
finally: finally:
@@ -8354,6 +8862,11 @@ def notifications_unread_status():
except Exception: except Exception:
is_admin_user = False is_admin_user = False
cached_payload, version_tag = _get_cached_unread_status(username, is_admin=is_admin_user)
if cached_payload is not None:
cached_etag = _build_unread_status_etag(version_tag, cached_payload)
return _build_cached_json_response(cached_payload, cached_etag)
client = None client = None
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -8397,11 +8910,15 @@ def notifications_unread_status():
'severity': latest_unread.get('Severity', 'info'), 'severity': latest_unread.get('Severity', 'info'),
} }
return jsonify({ payload = {
'ok': True, 'ok': True,
'unread_count': unread_count, 'unread_count': unread_count,
'latest_unread': latest_payload, 'latest_unread': latest_payload,
}) }
_set_cached_unread_status(username, is_admin_user, version_tag, payload)
etag_value = _build_unread_status_etag(version_tag, payload)
return _build_cached_json_response(payload, etag_value)
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}") app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500 return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
+1
View File
@@ -9,6 +9,7 @@ apscheduler
python-dateutil python-dateutil
pytz pytz
requests requests
redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
+6 -4
View File
@@ -155,10 +155,12 @@ select:focus {
@media (max-width: 900px) { @media (max-width: 900px) {
.container { .container {
width: calc(100% - 18px); width: 100%;
padding: 14px; max-width: 100%;
margin: 10px auto; padding: 12px 12px 18px;
border-radius: 10px; margin: 0;
border-radius: 0;
box-sizing: border-box;
} }
} }
+385 -13
View File
@@ -137,6 +137,10 @@
z-index: 1900; z-index: 1900;
} }
.navbar .container-fluid {
gap: 10px;
}
.navbar-brand { .navbar-brand {
font-weight: bold; font-weight: bold;
font-size: 1.4rem; font-size: 1.4rem;
@@ -152,6 +156,51 @@
font-weight: 600; font-weight: 600;
padding: 0.6rem 0.85rem; padding: 0.6rem 0.85rem;
border-radius: 8px; border-radius: 8px;
transition: padding .18s ease, font-size .18s ease;
}
.navbar.nav-compact .navbar-brand {
font-size: 1.18rem;
}
.navbar.nav-compact .navbar-nav .nav-link {
font-size: 0.93rem;
padding: 0.48rem 0.62rem;
}
.navbar.nav-compact .function-search-wrap {
width: min(320px, 34vw);
}
.navbar.nav-compact .function-search-input,
.navbar.nav-compact .function-search-btn {
min-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
}
.navbar.nav-ultra-compact .navbar-brand {
font-size: 1.04rem;
}
.navbar.nav-ultra-compact .navbar-nav .nav-link {
font-size: 0.86rem;
padding: 0.4rem 0.52rem;
}
.navbar.nav-ultra-compact .function-search-wrap {
width: min(270px, 30vw);
}
.navbar.nav-ultra-compact .function-search-btn {
padding-left: 9px;
padding-right: 9px;
font-size: 0.82rem;
}
.navbar.nav-ultra-compact .navbar-text {
margin-right: 0.45rem !important;
font-size: 0.86rem;
} }
.navbar-nav .nav-link.nav-active { .navbar-nav .nav-link.nav-active {
@@ -493,22 +542,194 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.module-selector-bar { .navbar {
padding: 8px 12px; border-bottom-left-radius: 14px;
border-bottom-right-radius: 14px;
}
.navbar .container-fluid {
align-items: flex-start;
flex-wrap: wrap;
padding-top: 0.2rem;
padding-bottom: 0.35rem;
}
.navbar-brand {
order: 1;
flex: 1 1 auto;
min-width: 0;
font-size: 1.08rem;
line-height: 1.1;
display: flex;
align-items: center;
gap: 6px;
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.navbar-toggler {
order: 2;
margin-left: auto;
flex: 0 0 auto;
align-self: flex-start;
}
.navbar-collapse {
order: 4;
width: 100%;
margin-top: 8px;
padding: 10px 10px 6px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.navbar-collapse.show,
.navbar-collapse.collapsing {
display: block;
}
.navbar-nav {
width: 100%;
align-items: stretch;
gap: 8px; gap: 8px;
padding-top: 2px;
}
.navbar-nav .nav-item {
width: 100%;
}
.navbar-nav .nav-link {
width: 100%;
justify-content: flex-start;
min-height: 46px;
padding: 0.8rem 0.95rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.navbar-nav .nav-link.nav-active,
.navbar-nav .nav-link.quick-link-pill,
.navbar-nav .nav-link.nav-priority-link {
background: rgba(255, 255, 255, 0.16);
}
.navbar-nav .nav-item.dropdown {
width: 100%;
}
.navbar-nav .nav-item.dropdown .nav-link.dropdown-toggle {
width: 100%;
justify-content: space-between;
}
.navbar-nav .dropdown-menu {
width: 100%;
margin-left: 0;
margin-top: 6px;
border-radius: 12px;
}
.module-selector-bar {
padding: 8px 10px;
gap: 8px;
flex-wrap: wrap;
} }
.module-selector-bar .module-label { .module-selector-bar .module-label {
display: none; display: none;
} }
.module-selector-bar .module-tabs {
width: 100%;
gap: 8px;
}
.module-selector-bar .module-tab { .module-selector-bar .module-tab {
padding: 5px 12px; width: 100%;
font-size: 0.9rem; text-align: center;
padding: 8px 12px;
font-size: 0.92rem;
} }
.module-separator { .module-separator {
height: 18px; display: none;
}
.function-search-wrap {
order: 3;
width: 100%;
margin: 0;
}
.function-search-form {
width: 100%;
flex-wrap: nowrap;
gap: 8px;
}
.function-search-input {
flex: 1 1 auto;
min-width: 0;
min-height: 44px;
}
.function-search-btn {
flex: 0 0 auto;
min-height: 44px;
min-width: 72px;
white-space: nowrap;
}
.navbar-text {
order: 5;
width: 100%;
margin: 0 !important;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
text-align: center;
}
.user-menu-wrap {
order: 6;
width: 100%;
margin-right: 0 !important;
}
.user-menu-btn {
width: 100%;
min-height: 46px;
border-radius: 12px;
justify-content: center;
}
.dropdown-menu-end {
width: 100%;
}
@media (max-width: 520px) {
.function-search-form {
flex-direction: column;
}
.function-search-input,
.function-search-btn,
.user-menu-btn,
.module-selector-bar .module-tab {
width: 100%;
}
.module-selector-bar .module-tabs {
flex-direction: column;
}
.navbar-text {
font-size: 0.88rem;
}
} }
} }
</style> </style>
@@ -548,18 +769,24 @@
</button> </button>
<div class="collapse navbar-collapse" id="inventoryNavContent"> <div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList"> <ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a> <a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
</li> </li>
{% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</li> </li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %} {% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li> </li>
@@ -570,24 +797,46 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li> <li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li> <li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('logs', True) %}
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li> <li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li> <li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li> <li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li> <li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li> <li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul> </ul>
@@ -616,7 +865,9 @@
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span> <span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li> <li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@@ -639,18 +890,24 @@
</button> </button>
<div class="collapse navbar-collapse" id="libraryNavContent"> <div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList"> <ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a> <a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
</li> </li>
{% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
</li> </li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %} {% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li> </li>
@@ -661,23 +918,37 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li> <li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li> <li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %} {% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li> <li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li> <li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li> <li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li> <li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul> </ul>
@@ -706,7 +977,9 @@
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span> <span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li> <li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@@ -1233,6 +1506,27 @@
function initNavbarOverflow(navList, navOverflowAnchor) { function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return; if (!navList || !navOverflowAnchor) return;
const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse');
const navContainer = navList.closest('.container-fluid') || navList.parentElement;
function applyCompactMode() {
if (!navRoot || !navContainer) return;
const width = navContainer.clientWidth || window.innerWidth;
navRoot.classList.remove('nav-compact', 'nav-ultra-compact');
if (window.innerWidth < 992) {
return;
}
if (width < 1220) {
navRoot.classList.add('nav-compact');
}
if (width < 1080) {
navRoot.classList.add('nav-ultra-compact');
}
}
function collectTopLevelNavSources() { function collectTopLevelNavSources() {
if (!navList) return []; if (!navList) return [];
return Array.from(navList.children).filter(function(li){ return Array.from(navList.children).filter(function(li){
@@ -1267,13 +1561,67 @@
li.className = 'nav-item dropdown'; li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true'; li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML = li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="overflowMenuToggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">⋮ Weitere</a>' + '<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="overflowMenuToggle" id="overflowMenu"></ul>'; '<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li; return li;
} }
function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) {
return -1;
}
const topLink = item.querySelector(':scope > a.nav-link');
if (!topLink) {
return 10;
}
if (topLink.classList.contains('nav-active')) {
return 1000;
}
if (topLink.classList.contains('nav-priority-link')) {
return 900;
}
if (topLink.classList.contains('quick-link-pill')) {
return 700;
}
if (item.classList.contains('dropdown')) {
return 300;
}
return 500;
}
function pickNextNavItemToHide(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) {
return null;
}
let selected = candidates[0];
let selectedIndex = 0;
let selectedPriority = getNavCandidatePriority(selected);
for (let i = 1; i < candidates.length; i += 1) {
const candidate = candidates[i];
const candidatePriority = getNavCandidatePriority(candidate);
if (candidatePriority < selectedPriority || (candidatePriority === selectedPriority && i > selectedIndex)) {
selected = candidate;
selectedIndex = i;
selectedPriority = candidatePriority;
}
}
return selected;
}
function appendSourceToOverflowMenu(sourceItem, menu) { function appendSourceToOverflowMenu(sourceItem, menu) {
const topLink = sourceItem.querySelector(':scope > a.nav-link'); const topLink = sourceItem.querySelector(':scope > a.nav-link');
if (!topLink) return; if (!topLink) return;
@@ -1309,6 +1657,10 @@
const control = createOverflowControl(); const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu'); const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
hiddenSources.forEach(function(source){ hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu); appendSourceToOverflowMenu(source, menu);
@@ -1325,6 +1677,8 @@
function adaptNavbarByWidth() { function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return; if (!navList || !navOverflowAnchor) return;
applyCompactMode();
if (window.innerWidth < 992) { if (window.innerWidth < 992) {
restoreAllNavItems(); restoreAllNavItems();
return; return;
@@ -1336,7 +1690,10 @@
let candidates = collectTopLevelNavSources(); let candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1]; const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none'; toHide.style.display = 'none';
hiddenSources.unshift(toHide); hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources(); candidates = collectTopLevelNavSources();
@@ -1346,7 +1703,10 @@
candidates = collectTopLevelNavSources(); candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1]; const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none'; toHide.style.display = 'none';
hiddenSources.unshift(toHide); hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources); rebuildOverflowControl(hiddenSources);
@@ -1364,6 +1724,18 @@
adaptNavbarByWidth(); adaptNavbarByWidth();
window.addEventListener('resize', debounceAdapt); window.addEventListener('resize', debounceAdapt);
if (navCollapse) {
navCollapse.addEventListener('shown.bs.collapse', debounceAdapt);
navCollapse.addEventListener('hidden.bs.collapse', debounceAdapt);
}
if ('ResizeObserver' in window && navContainer) {
const resizeObserver = new ResizeObserver(function() {
debounceAdapt();
});
resizeObserver.observe(navContainer);
}
} }
// Initialize overflow control for inventory navbar // Initialize overflow control for inventory navbar
+51 -15
View File
@@ -774,8 +774,18 @@
return; return;
} }
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth); const styles = window.getComputedStyle(itemsContainer);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4); const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) { if (distanceToEnd > prefetchThreshold) {
return; return;
} }
@@ -833,7 +843,7 @@
const favoriteIds = new Set(data.favorites || []); const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds; window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){} try { sessionStorage.setItem('favoritesCache:' + ({{ session.get('username', '') | tojson }} || 'anon'), JSON.stringify(Array.from(favoriteIds))); } catch(e){}
pageItems.forEach(item => { pageItems.forEach(item => {
try { try {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -1142,6 +1152,12 @@
const itemsContainer = ensureMainItemsSentinel(); const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return; if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainItemsObserver) { if (mainItemsObserver) {
mainItemsObserver.disconnect(); mainItemsObserver.disconnect();
mainItemsObserver = null; mainItemsObserver = null;
@@ -1172,7 +1188,7 @@
}, { }, {
root: itemsContainer, root: itemsContainer,
threshold: 0.15, threshold: 0.15,
rootMargin: '0px 900px 0px 0px' rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
}); });
mainItemsObserver.observe(mainItemsSentinel); mainItemsObserver.observe(mainItemsSentinel);
@@ -2942,6 +2958,22 @@
animation: items-loader-slide 1.05s ease-in-out infinite; animation: items-loader-slide 1.05s ease-in-out infinite;
} }
@media (max-width: 768px) {
.items-loading-indicator {
width: 100%;
min-width: 0;
max-width: none;
min-height: 120px;
height: auto;
padding: 14px 10px;
scroll-snap-align: none;
}
.items-loading-indicator .loading-track {
width: min(240px, 82%);
}
}
@keyframes items-loader-slide { @keyframes items-loader-slide {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
100% { transform: translateX(260%); } 100% { transform: translateX(260%); }
@@ -3342,9 +3374,12 @@
/* Mobile-responsive styles for user interface */ /* Mobile-responsive styles for user interface */
.container { .container {
width: 95% !important; width: 100% !important;
margin: 10px auto !important; max-width: 100% !important;
padding: 15px !important; margin: 0 !important;
padding: 12px 12px 18px !important;
border-radius: 0 !important;
box-sizing: border-box !important;
} }
h1, h2 { h1, h2 {
@@ -3414,7 +3449,7 @@
display: grid !important; display: grid !important;
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
gap: 15px !important; gap: 15px !important;
padding: 10px 0 !important; padding: 10px 0 0 !important;
overflow-x: visible !important; overflow-x: visible !important;
} }
@@ -3431,12 +3466,12 @@
/* Modal improvements for mobile */ /* Modal improvements for mobile */
.modal-content { .modal-content {
width: 95% !important; width: calc(100vw - 16px) !important;
max-width: 500px !important; max-width: none !important;
margin: 20px auto !important; margin: 8px auto !important;
max-height: 85vh !important; max-height: 85vh !important;
overflow-y: auto !important; overflow-y: auto !important;
padding: 20px !important; padding: 16px !important;
} }
/* Button improvements */ /* Button improvements */
@@ -4198,6 +4233,7 @@
<script> <script>
let favoritesOnly = false; let favoritesOnly = false;
let tableViewMode = false; let tableViewMode = false;
const favoritesCacheKey = 'favoritesCache:' + ({{ session.get('username', '') | tojson }} || 'anon');
function setViewModeState() { function setViewModeState() {
document.body.classList.toggle('table-view', tableViewMode); document.body.classList.toggle('table-view', tableViewMode);
@@ -4228,7 +4264,7 @@ function toggleFavorite(id, btn, card){
} }
if(!window.currentFavorites) window.currentFavorites = new Set(); if(!window.currentFavorites) window.currentFavorites = new Set();
if(isFav) window.currentFavorites.add(id); else window.currentFavorites.delete(id); if(isFav) window.currentFavorites.add(id); else window.currentFavorites.delete(id);
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(window.currentFavorites))); } catch(e){} try { sessionStorage.setItem(favoritesCacheKey, JSON.stringify(Array.from(window.currentFavorites))); } catch(e){}
}) })
.catch(err=>console.error('Netzwerkfehler Favoriten', err)); .catch(err=>console.error('Netzwerkfehler Favoriten', err));
} }
@@ -4236,7 +4272,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Initialize favorites cache set if stored // Initialize favorites cache set if stored
try { try {
if(!window.currentFavorites){ if(!window.currentFavorites){
const cached = sessionStorage.getItem('favoritesCache'); const cached = sessionStorage.getItem(favoritesCacheKey);
if(cached){ window.currentFavorites = new Set(JSON.parse(cached)); } if(cached){ window.currentFavorites = new Set(JSON.parse(cached)); }
} }
} catch(e){} } catch(e){}
@@ -4275,7 +4311,7 @@ function openItemQuick(id){
if(item && !item.error){ if(item && !item.error){
// ensure favorites set available // ensure favorites set available
if(!window.currentFavorites){ if(!window.currentFavorites){
window.currentFavorites = new Set(JSON.parse(sessionStorage.getItem('favoritesCache')||'[]')); window.currentFavorites = new Set(JSON.parse(sessionStorage.getItem(favoritesCacheKey)||'[]'));
} }
openItemModal(item); openItemModal(item);
} }
+61 -21
View File
@@ -369,6 +369,22 @@
animation: items-loader-slide 1.05s ease-in-out infinite; animation: items-loader-slide 1.05s ease-in-out infinite;
} }
@media (max-width: 768px) {
.items-loading-indicator {
width: 100%;
min-width: 0;
max-width: none;
min-height: 120px;
height: auto;
padding: 14px 10px;
scroll-snap-align: none;
}
.items-loading-indicator .loading-track {
width: min(240px, 82%);
}
}
@keyframes items-loader-slide { @keyframes items-loader-slide {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
100% { transform: translateX(260%); } 100% { transform: translateX(260%); }
@@ -1221,9 +1237,11 @@
/* Mobile-responsive styles for admin interface */ /* Mobile-responsive styles for admin interface */
.admin-content-container { .admin-content-container {
width: 95% !important; width: 100% !important;
margin: 10px auto !important; max-width: 100% !important;
padding: 15px !important; margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
overflow-x: hidden !important; overflow-x: hidden !important;
} }
@@ -1347,11 +1365,12 @@
/* Modal improvements for admin */ /* Modal improvements for admin */
.modal-content { .modal-content {
width: 95% !important; width: calc(100vw - 16px) !important;
max-width: 500px !important; max-width: none !important;
margin: 20px auto !important; margin: 8px auto !important;
max-height: 85vh !important; max-height: 85vh !important;
overflow-y: auto !important; overflow-y: auto !important;
padding: 16px !important;
} }
/* Admin card improvements */ /* Admin card improvements */
@@ -1510,9 +1529,11 @@
/* Mobile-responsive styles for admin interface */ /* Mobile-responsive styles for admin interface */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.admin-content-container { .admin-content-container {
width: 95% !important; width: 100% !important;
margin: 10px auto !important; max-width: 100% !important;
padding: 15px !important; margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
} }
h1, h2 { h1, h2 {
@@ -1599,12 +1620,12 @@
/* Modal improvements for admin */ /* Modal improvements for admin */
.modal-content { .modal-content {
width: 95% !important; width: calc(100vw - 16px) !important;
max-width: 500px !important; max-width: none !important;
margin: 20px auto !important; margin: 8px auto !important;
max-height: 85vh !important; max-height: 85vh !important;
overflow-y: auto !important; overflow-y: auto !important;
padding: 20px !important; padding: 16px !important;
} }
/* Button improvements */ /* Button improvements */
@@ -1792,9 +1813,11 @@
} }
.admin-content-container { .admin-content-container {
width: 95%; width: 100%;
margin: 10px auto; max-width: 100%;
padding: 15px; margin: 0;
padding: 12px 12px 18px;
box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -1808,12 +1831,13 @@
/* Better modal sizing for mobile */ /* Better modal sizing for mobile */
.modal-content { .modal-content {
width: 95% !important; width: calc(100vw - 16px) !important;
max-width: none !important; max-width: none !important;
margin: 10px auto !important; margin: 8px auto !important;
max-height: 90vh !important; max-height: 90vh !important;
overflow-y: auto !important; overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important; -webkit-overflow-scrolling: touch !important;
padding: 16px !important;
} }
} }
@@ -3366,8 +3390,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
return; return;
} }
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth); const styles = window.getComputedStyle(itemsContainer);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4); const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) { if (distanceToEnd > prefetchThreshold) {
return; return;
} }
@@ -3729,6 +3763,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
const itemsContainer = ensureMainAdminItemsSentinel(); const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return; if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainAdminItemsObserver) { if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect(); mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null; mainAdminItemsObserver = null;
@@ -3759,7 +3799,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, { }, {
root: itemsContainer, root: itemsContainer,
threshold: 0.15, threshold: 0.15,
rootMargin: '0px 900px 0px 0px' rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
}); });
mainAdminItemsObserver.observe(mainAdminItemsSentinel); mainAdminItemsObserver.observe(mainAdminItemsSentinel);
+216 -9
View File
@@ -39,21 +39,31 @@
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
<input type="text" id="username" name="username" placeholder="Geben Sie einen Benutzernamen ein" required> <input type="text" id="username" name="username" placeholder="Geben Sie einen Benutzernamen ein" required>
</div> </div>
<label for="name">Name</label> <label for="name">Vorname</label>
<div class="input-container"> <div class="input-container">
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Namen ein" required> <input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required>
</div> </div>
<label for="last-name">Nachname</label> <label for="last-name">Nachname</label>
<div class="input-container"> <div class="input-container">
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required> <input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required>
</div> </div>
<p class="anonymize-hint">Klarnamen werden nur zur Erzeugung eines Kuerzels (z.B. SimFri) verwendet und nicht als Klarname gespeichert.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Passwort</label> <label for="password">Passwort</label>
<a class="richtlinen">Das Password muss mindestens 6 Zeichen beinhalten mit Sonderzeichen, groß und klein Buchstaben sowie Zahlen!</a> <div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
<li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li>
<li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li>
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul>
</div>
<div class="input-container"> <div class="input-container">
<span class="input-icon">🔒</span> <span class="input-icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required> <input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required>
@@ -81,6 +91,43 @@
</div> </div>
{% endif %} {% endif %}
<div class="form-group">
<label for="permission-preset">Berechtigungs-Preset</label>
<select id="permission-preset" name="permission_preset" class="form-select">
{% for preset_key, preset in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<label style="display:flex; align-items:center; gap:8px; margin-top:12px; color:#1f2937;">
<input type="checkbox" id="use-custom-permissions" name="use_custom_permissions" style="width:auto;">
Individuelle Berechtigungen statt Preset setzen
</label>
<div id="custom-permissions" style="display:none; margin-top:12px;">
<div class="permission-panels">
<div class="permission-panel">
<h4>Aktionsrechte</h4>
{% for action_key, action_label in permission_action_options %}
<label class="permission-check">
<input type="checkbox" class="permission-action-checkbox" name="action_{{ action_key }}">
<span>{{ action_label }}</span>
</label>
{% endfor %}
</div>
<div class="permission-panel">
<h4>Seitenrechte</h4>
{% for endpoint_name, endpoint_label in permission_page_options %}
<label class="permission-check">
<input type="checkbox" class="permission-page-checkbox" name="page_{{ endpoint_name }}">
<span>{{ endpoint_label }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="form-group form-actions"> <div class="form-group form-actions">
<button type="submit" class="action-button register-button">Benutzer registrieren</button> <button type="submit" class="action-button register-button">Benutzer registrieren</button>
</div> </div>
@@ -174,7 +221,8 @@ body {
} }
input[type="text"], input[type="text"],
input[type="password"] { input[type="password"],
.form-select {
width: 100%; width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem; padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd; border: 1px solid #ddd;
@@ -185,12 +233,17 @@ input[type="password"] {
} }
input[type="text"]:focus, input[type="text"]:focus,
input[type="password"]:focus { input[type="password"]:focus,
.form-select:focus {
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2); box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none; outline: none;
} }
.form-select {
padding-left: 1rem;
}
input::placeholder { input::placeholder {
color: #aaa; color: #aaa;
} }
@@ -308,14 +361,168 @@ input::placeholder {
} }
} }
.richtlinen{ .password-rules {
color: #ec0920; margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.password-rules-title {
margin: 0 0 8px;
font-weight: 700;
color: #1f2937;
font-size: 0.92rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 22px;
margin: 5px 0;
color: #b91c1c;
font-size: 0.9rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.anonymize-hint {
margin-top: 10px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 10px 12px;
color: #0c4a6e;
font-size: 0.92rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: #f8fafc;
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 1rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #1f2937;
} }
</style> </style>
{% if student_cards_module_enabled %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const permissionPresets = {{ permission_presets | tojson }};
const presetSelect = document.getElementById('permission-preset');
const useCustomPermissions = document.getElementById('use-custom-permissions');
const customPermissions = document.getElementById('custom-permissions');
function applyPresetToPermissionForm(presetKey) {
const preset = permissionPresets[presetKey] || {};
const actionDefaults = preset.actions || {};
const pageDefaults = preset.pages || {};
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
const key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionDefaults[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
const key = checkbox.name.replace('page_', '');
checkbox.checked = !!pageDefaults[key];
});
}
function toggleCustomPermissions() {
if (!useCustomPermissions || !customPermissions) {
return;
}
customPermissions.style.display = useCustomPermissions.checked ? 'block' : 'none';
}
if (presetSelect) {
presetSelect.addEventListener('change', function () {
applyPresetToPermissionForm(this.value);
});
applyPresetToPermissionForm(presetSelect.value);
}
if (useCustomPermissions) {
useCustomPermissions.addEventListener('change', toggleCustomPermissions);
toggleCustomPermissions();
}
const passwordInput = document.getElementById('password');
const passwordRules = {
length: document.getElementById('pw-rule-length'),
lower: document.getElementById('pw-rule-lower'),
upper: document.getElementById('pw-rule-upper'),
digit: document.getElementById('pw-rule-digit'),
symbol: document.getElementById('pw-rule-symbol')
};
function setRuleState(node, ok) {
if (!node) {
return;
}
node.classList.toggle('ok', !!ok);
}
function updatePasswordRules() {
if (!passwordInput) {
return;
}
const value = String(passwordInput.value || '');
setRuleState(passwordRules.length, value.length >= 12);
setRuleState(passwordRules.lower, /[a-z]/.test(value));
setRuleState(passwordRules.upper, /[A-Z]/.test(value));
setRuleState(passwordRules.digit, /[0-9]/.test(value));
setRuleState(passwordRules.symbol, /[^A-Za-z0-9]/.test(value));
}
if (passwordInput) {
passwordInput.addEventListener('input', updatePasswordRules);
passwordInput.addEventListener('blur', updatePasswordRules);
updatePasswordRules();
}
{% if student_cards_module_enabled %}
const studentCheckbox = document.getElementById('is-student'); const studentCheckbox = document.getElementById('is-student');
const studentFields = document.getElementById('student-fields'); const studentFields = document.getElementById('student-fields');
const studentCardInput = document.getElementById('student-card-id'); const studentCardInput = document.getElementById('student-card-id');
@@ -332,7 +539,7 @@ document.addEventListener('DOMContentLoaded', function () {
studentCheckbox.addEventListener('change', toggleStudentFields); studentCheckbox.addEventListener('change', toggleStudentFields);
toggleStudentFields(); toggleStudentFields();
{% endif %}
}); });
</script> </script>
{% endif %}
{% endblock %} {% endblock %}
+168
View File
@@ -17,6 +17,16 @@
<div class="user-management-container"> <div class="user-management-container">
<h2>Benutzer</h2> <h2>Benutzer</h2>
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
<button
type="submit"
class="btn btn-outline-danger"
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
>
Gespeicherte Namen anonymisieren
</button>
</form>
<div class="filter-bar mb-3"> <div class="filter-bar mb-3">
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-md-3"> <div class="col-md-3">
@@ -62,6 +72,7 @@
<th>Vorname</th> <th>Vorname</th>
<th>Nachname</th> <th>Nachname</th>
<th>Administrator</th> <th>Administrator</th>
<th>Rechte-Preset</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -72,6 +83,7 @@
<td>{{ user.name if user.name else user.username }}</td> <td>{{ user.name if user.name else user.username }}</td>
<td>{{ user.last_name if user.last_name else '' }}</td> <td>{{ user.last_name if user.last_name else '' }}</td>
<td>{{ "Ja" if user.admin else "Nein" }}</td> <td>{{ "Ja" if user.admin else "Nein" }}</td>
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
<td class="actions"> <td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline"> <form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="username" value="{{ user.username }}"> <input type="hidden" name="username" value="{{ user.username }}">
@@ -90,6 +102,14 @@
onclick="openResetPasswordModal('{{ user.username }}')"> onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen Passwort zurücksetzen
</button> </button>
<button type="button" class="btn btn-info btn-sm"
data-username="{{ user.username }}"
data-preset="{{ user.permission_preset }}"
data-action-permissions='{{ user.action_permissions | tojson }}'
data-page-permissions='{{ user.page_permissions | tojson }}'
onclick="openPermissionsModal(this)">
Berechtigungen
</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -99,6 +119,62 @@
</div> </div>
</div> </div>
<!-- Permission Modal -->
<div class="modal fade" id="permissionsModal" tabindex="-1" aria-labelledby="permissionsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="permissionsModalLabel">Berechtigungen bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
<div class="modal-body permissions-modal-body">
<input type="hidden" id="perm-username" name="username">
<div class="mb-3">
<label for="perm-username-display" class="form-label">Benutzer</label>
<input type="text" class="form-control" id="perm-username-display" disabled>
</div>
<div class="mb-3">
<label for="permission-preset" class="form-label">Preset</label>
<select class="form-select" id="permission-preset" name="permission_preset">
{% for preset_key, preset_value in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset_value.label }}</option>
{% endfor %}
</select>
</div>
<div class="permissions-grid">
<div class="permissions-group">
<h6>Aktionsrechte</h6>
{% for action_key, action_label in permission_action_options %}
<div class="form-check">
<input class="form-check-input permission-action-checkbox" type="checkbox" id="action-{{ action_key }}" name="action_{{ action_key }}">
<label class="form-check-label" for="action-{{ action_key }}">{{ action_label }}</label>
</div>
{% endfor %}
</div>
<div class="permissions-group">
<h6>Seitenrechte</h6>
{% for endpoint_name, endpoint_label in permission_page_options %}
<div class="form-check">
<input class="form-check-input permission-page-checkbox" type="checkbox" id="page-{{ endpoint_name }}" name="page_{{ endpoint_name }}">
<label class="form-check-label" for="page-{{ endpoint_name }}">{{ endpoint_label }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Berechtigungen speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true"> <div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@@ -165,6 +241,24 @@
</div> </div>
<script> <script>
var permissionPresets = {{ permission_presets | tojson }};
function applyPresetToPermissionForm(presetKey) {
var preset = permissionPresets[presetKey] || {};
var actionDefaults = preset.actions || {};
var pageDefaults = preset.pages || {};
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionDefaults[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('page_', '');
checkbox.checked = !!pageDefaults[key];
});
}
function applyFilters() { function applyFilters() {
var search = document.getElementById('filter-search').value.toLowerCase(); var search = document.getElementById('filter-search').value.toLowerCase();
var adminFilter = document.getElementById('filter-admin').value.toLowerCase(); var adminFilter = document.getElementById('filter-admin').value.toLowerCase();
@@ -229,6 +323,13 @@
document.getElementById('filter-admin').addEventListener('change', applyFilters); document.getElementById('filter-admin').addEventListener('change', applyFilters);
document.getElementById('filter-column').addEventListener('change', applyFilters); document.getElementById('filter-column').addEventListener('change', applyFilters);
document.getElementById('filter-direction').addEventListener('change', applyFilters); document.getElementById('filter-direction').addEventListener('change', applyFilters);
var presetSelect = document.getElementById('permission-preset');
if (presetSelect) {
presetSelect.addEventListener('change', function () {
applyPresetToPermissionForm(this.value);
});
}
}); });
function openEditUserModal(button) { function openEditUserModal(button) {
@@ -253,6 +354,42 @@
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show(); modal.show();
} }
function openPermissionsModal(button) {
var username = button.getAttribute('data-username') || '';
var preset = button.getAttribute('data-preset') || 'standard_user';
var actionPermissions = {};
var pagePermissions = {};
try {
actionPermissions = JSON.parse(button.getAttribute('data-action-permissions') || '{}');
} catch (err) {
actionPermissions = {};
}
try {
pagePermissions = JSON.parse(button.getAttribute('data-page-permissions') || '{}');
} catch (err) {
pagePermissions = {};
}
document.getElementById('perm-username').value = username;
document.getElementById('perm-username-display').value = username;
document.getElementById('permission-preset').value = preset;
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionPermissions[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('page_', '');
checkbox.checked = !!pagePermissions[key];
});
var modal = new bootstrap.Modal(document.getElementById('permissionsModal'));
modal.show();
}
</script> </script>
<style> <style>
@@ -286,5 +423,36 @@
.password-requirements p { .password-requirements p {
margin-bottom: 5px; margin-bottom: 5px;
} }
.permissions-modal-body {
max-height: 70vh;
overflow-y: auto;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.permissions-group {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px;
background: #f8fafc;
}
.permissions-group h6 {
font-weight: 700;
margin-bottom: 10px;
}
.modal-backdrop {
z-index: 1998 !important;
}
.modal {
z-index: 1999 !important;
}
</style> </style>
{% endblock %} {% endblock %}
+284 -5
View File
@@ -11,6 +11,8 @@ Provides methods for creating, validating, and retrieving user information.
For commercial licensing inquiries: https://github.com/AIIrondev For commercial licensing inquiries: https://github.com/AIIrondev
''' '''
import hashlib import hashlib
import copy
import re
from bson.objectid import ObjectId from bson.objectid import ObjectId
import settings as cfg import settings as cfg
from settings import MongoClient from settings import MongoClient
@@ -23,6 +25,256 @@ def normalize_student_card_id(card_id):
return str(card_id).strip().upper() return str(card_id).strip().upper()
def _clean_name_fragment(value):
cleaned = re.sub(r'[^A-Za-zÄÖÜäöüß]', '', str(value or '').strip())
if not cleaned:
return ''
replacements = {
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'ß': 'ss',
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
}
for old_char, new_char in replacements.items():
cleaned = cleaned.replace(old_char, new_char)
return cleaned
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
ACTION_PERMISSION_KEYS = (
'can_borrow',
'can_insert',
'can_edit',
'can_delete',
'can_manage_users',
'can_manage_settings',
'can_view_logs',
)
DEFAULT_ACTION_PERMISSIONS = {
'can_borrow': True,
'can_insert': False,
'can_edit': False,
'can_delete': False,
'can_manage_users': False,
'can_manage_settings': False,
'can_view_logs': False,
}
DEFAULT_PAGE_PERMISSIONS = {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': False,
'upload_admin': False,
'library_admin': False,
'admin_borrowings': False,
'library_loans_admin': False,
'admin_damaged_items': False,
'admin_audit_dashboard': False,
'logs': False,
'user_del': False,
'register': False,
'manage_filters': False,
'manage_locations': False,
}
PERMISSION_PRESETS = {
'standard_user': {
'label': 'Standard (Ausleihe)',
'actions': {
'can_borrow': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
},
},
'editor': {
'label': 'Editor (Einfügen/Bearbeiten)',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'upload_admin': True,
'library_admin': True,
},
},
'manager': {
'label': 'Manager (inkl. Löschen)',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
},
'full_access': {
'label': 'Vollzugriff',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'user_del': True,
'register': True,
'manage_filters': True,
'manage_locations': True,
},
},
}
def _normalize_bool_map(source, defaults):
result = dict(defaults)
if isinstance(source, dict):
for key, value in source.items():
result[str(key)] = bool(value)
return result
def get_permission_preset_definitions():
return copy.deepcopy(PERMISSION_PRESETS)
def build_default_permission_payload(preset_key='standard_user'):
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
preset = PERMISSION_PRESETS.get(selected_key, {})
action_defaults = _normalize_bool_map(preset.get('actions', {}), DEFAULT_ACTION_PERMISSIONS)
page_defaults = _normalize_bool_map(preset.get('pages', {}), DEFAULT_PAGE_PERMISSIONS)
return {
'preset': selected_key,
'actions': action_defaults,
'pages': page_defaults,
}
def get_effective_permissions(username):
user = get_user(username)
if not user:
return build_default_permission_payload('standard_user')
# Admin users always have full access, independent of custom presets.
if bool(user.get('Admin', False)):
return build_default_permission_payload('full_access')
preset_key = user.get('PermissionPreset') or 'standard_user'
payload = build_default_permission_payload(preset_key)
payload['actions'] = _normalize_bool_map(user.get('ActionPermissions', {}), payload['actions'])
payload['pages'] = _normalize_bool_map(user.get('PagePermissions', {}), payload['pages'])
return payload
def update_user_permissions(username, preset_key, action_permissions=None, page_permissions=None):
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
payload = build_default_permission_payload(selected_key)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
payload['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
payload['pages'][str(key)] = bool(value)
update_data = {
'PermissionPreset': payload['preset'],
'ActionPermissions': payload['actions'],
'PagePermissions': payload['pages'],
}
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': update_data})
client.close()
return result.matched_count > 0
# === FAVORITES MANAGEMENT === # === FAVORITES MANAGEMENT ===
def get_favorites(username): def get_favorites(username):
"""Return a list of favorite item ObjectId strings for the user.""" """Return a list of favorite item ObjectId strings for the user."""
@@ -128,7 +380,18 @@ def check_nm_pwd(username, password):
return user return user
def add_user(username, password, name, last_name, is_student=False, student_card_id=None, max_borrow_days=None): def add_user(
username,
password,
name='',
last_name='',
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset='standard_user',
action_permissions=None,
page_permissions=None,
):
""" """
Add a new user to the database. Add a new user to the database.
@@ -144,14 +407,29 @@ def add_user(username, password, name, last_name, is_student=False, student_card
users = db['users'] users = db['users']
if not check_password_strength(password): if not check_password_strength(password):
return False return False
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name, 'name': name_alias,
'last_name': last_name, 'last_name': '',
'IsStudent': bool(is_student) 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
'PagePermissions': permission_defaults['pages'],
} }
normalized_card = normalize_student_card_id(student_card_id) normalized_card = normalize_student_card_id(student_card_id)
@@ -494,13 +772,14 @@ def update_user_name(username, name, last_name):
bool: True if updated successfully, False otherwise bool: True if updated successfully, False otherwise
""" """
try: try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
users = db['users'] users = db['users']
result = users.update_one( result = users.update_one(
{'Username': username}, {'Username': username},
{'$set': {'name': name, 'last_name': last_name}} {'$set': {'name': name_alias, 'last_name': ''}}
) )
client.close() client.close()
+20
View File
@@ -24,6 +24,19 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app: app:
build: build:
context: . context: .
@@ -35,10 +48,16 @@ services:
depends_on: depends_on:
mongodb: mongodb:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
environment: environment:
INVENTAR_MONGODB_HOST: mongodb INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017" INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
@@ -64,3 +83,4 @@ volumes:
app_backups: app_backups:
app_logs: app_logs:
app_deleted_archives: app_deleted_archives:
redis_data:
+1
View File
@@ -8,6 +8,7 @@ apscheduler
python-dateutil python-dateutil
pytz pytz
requests requests
redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl