Compare commits

..

21 Commits

Author SHA1 Message Date
Aiirondev_dev 5d8213b8c2 Implement encryption for student card data and add support for archiving deleted media files 2026-04-17 17:14:08 +02:00
Aiirondev_dev b94fb1cd97 Enhance security and logging in application settings and templates; update impressum legal references 2026-04-17 16:54:53 +02:00
Aiirondev_dev ce20f569a3 Fix email link in impressum section 2026-04-13 21:58:14 +02:00
Aiirondev_dev ec7f32994c Remove notifications link and update impressum contact information 2026-04-13 21:44:06 +02:00
Aiirondev_dev 8358bf257f Add login notification hint for unread messages in user menu 2026-04-13 21:30:24 +02:00
Aiirondev_dev 193495d6a2 Enhance navbar with sticky positioning and search functionality, including active link highlighting and improved mobile responsiveness 2026-04-13 21:27:12 +02:00
Aiirondev_dev 5afcaa5e19 Add damage report link to notification details 2026-04-13 21:19:32 +02:00
Aiirondev_dev 3aa44b64b8 Add notification system with unread count and in-app alerts 2026-04-13 21:05:34 +02:00
Aiirondev_dev 86d8f19313 Implement notification system with user and admin views, including unread count and reminder functionality for damaged items 2026-04-13 20:29:50 +02:00
Maximilian G. e74798f252 Update support status for version 3.0.x 2026-04-13 12:10:03 +02:00
Aiirondev_dev 31c3d700a8 Update terminology from "Schülerausweise" to "Bibliotheksausweis" across admin and print templates for consistency 2026-04-12 21:31:11 +02:00
Aiirondev_dev a3c114e4b5 Refactor bulk delete functionality to toggle visibility of checkboxes in admin view 2026-04-12 21:21:50 +02:00
Aiirondev_dev 170a2a53ab Reduce Gunicorn worker count and adjust timeout settings for optimized performance 2026-04-12 20:57:41 +02:00
Aiirondev_dev a8ac08d5b0 Implement light mode for item loading to optimize initial data fetch 2026-04-12 20:36:13 +02:00
Aiirondev_dev 05c4c73635 Add loading indicator for main items in admin view to enhance user experience 2026-04-12 20:21:47 +02:00
Aiirondev_dev 76dd00831e Enhance item count display logic in admin view for improved accuracy 2026-04-12 20:02:19 +02:00
Aiirondev_dev 205a56ff24 Update filter display function calls for improved clarity in UI updates 2026-04-12 19:47:56 +02:00
Aiirondev_dev 60ec21f34e Implement lazy loading and prefetching for main admin items to enhance performance 2026-04-12 19:30:20 +02:00
Aiirondev_dev 57f6d2f8e7 Refactor MongoClient function to enhance configuration handling and preserve caller arguments 2026-04-12 19:18:30 +02:00
Aiirondev_dev 53575c1876 Refactor MongoDB client initialization for improved performance and simplicity 2026-04-12 17:50:24 +02:00
Aiirondev_dev 0f372f5056 Refactor MongoDB client imports and implement lazy loading for main items in UI 2026-04-12 17:36:49 +02:00
20 changed files with 2088 additions and 159 deletions
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+2 -1
View File
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
| Version | Supported |
| ------- | ------------------ |
| 0.2.17 | ✅ |
| 3.2.x | :white_check_mark: |
| 3.1.x | :x: |
| 3.0.x | :white_check_mark: |
| 3.0.x | :x: |
| 2.6.x | :x: |
| 2.4.x | :x: |
| 1.8.x | :x: |
+693 -88
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import pytz
@@ -34,6 +33,7 @@ import os
import json
import shutil
import settings as cfg
from settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
+176
View File
@@ -0,0 +1,176 @@
"""Helpers for targeted PII encryption and encrypted archival of deleted media files."""
import base64
import hashlib
import json
import os
import uuid
import zipfile
from datetime import datetime
from cryptography.fernet import Fernet, InvalidToken
import settings as cfg
_ENC_PREFIX = "enc::"
def _resolve_fernet_key():
"""Resolve the Fernet key from env/config or derive a stable fallback."""
configured_key = cfg.DATA_ENCRYPTION_KEY
if configured_key:
try:
# Validate the supplied key format.
Fernet(configured_key.encode("utf-8"))
return configured_key.encode("utf-8")
except Exception:
pass
# Fallback for compatibility: derive stable key from SECRET_KEY.
digest = hashlib.sha256(cfg.SECRET_KEY.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
def _fernet():
return Fernet(_resolve_fernet_key())
def encrypt_text(value):
"""Encrypt a text value. Keeps empty values unchanged."""
if value is None:
return None
text = str(value)
if text == "" or text.startswith(_ENC_PREFIX):
return text
token = _fernet().encrypt(text.encode("utf-8")).decode("utf-8")
return f"{_ENC_PREFIX}{token}"
def decrypt_text(value):
"""Decrypt an encrypted text value. Returns original value if not encrypted."""
if value is None:
return None
text = str(value)
if not text.startswith(_ENC_PREFIX):
return text
token = text[len(_ENC_PREFIX):]
try:
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError, TypeError):
# Keep data readable even if key rotation or malformed data occurred.
return text
def encrypt_document_fields(document, fields):
"""Encrypt selected fields of a document in-place and return it."""
for field in fields:
if field in document:
document[field] = encrypt_text(document.get(field))
return document
def decrypt_document_fields(document, fields):
"""Decrypt selected fields of a document in-place and return it."""
for field in fields:
if field in document:
document[field] = decrypt_text(document.get(field))
return document
def _candidate_media_paths(filename):
"""Return all possible filesystem paths for a stored media filename."""
name_part, _ = os.path.splitext(filename)
return [
(os.path.join(cfg.UPLOAD_FOLDER, filename), "originals"),
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.webp"), "originals"),
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.jpg"), "originals"),
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.webp"), "thumbnails"),
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.jpg"), "thumbnails"),
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.webp"), "previews"),
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.jpg"), "previews"),
]
def encrypt_soft_deleted_media_pack(item_docs, *, actor="system"):
"""
Archive media files referenced by item docs, encrypt the archive, and delete originals.
Uses ZIP_STORED (no compression) to keep CPU usage low.
"""
files_to_archive = []
seen_paths = set()
for item in item_docs:
item_id = str(item.get("_id", "unknown"))
for image_name in item.get("Images", []) or []:
for abs_path, bucket in _candidate_media_paths(str(image_name)):
if abs_path in seen_paths:
continue
if not os.path.isfile(abs_path):
continue
seen_paths.add(abs_path)
files_to_archive.append((item_id, str(image_name), abs_path, bucket))
if not files_to_archive:
return {
"archive_created": False,
"archived_files": 0,
"deleted_files": 0,
"archive_path": None,
}
os.makedirs(cfg.DELETED_ARCHIVE_FOLDER, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
archive_id = f"softdelete-{timestamp}-{uuid.uuid4().hex[:8]}"
zip_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip")
encrypted_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip.enc")
manifest = {
"archive_id": archive_id,
"created_at": datetime.utcnow().isoformat() + "Z",
"actor": actor,
"files": [],
}
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_STORED) as zf:
for idx, (item_id, original_name, abs_path, bucket) in enumerate(files_to_archive, start=1):
safe_name = os.path.basename(abs_path)
arcname = f"{bucket}/{item_id}/{idx:04d}-{safe_name}"
zf.write(abs_path, arcname)
manifest["files"].append(
{
"item_id": item_id,
"source_name": original_name,
"stored_as": arcname,
"size_bytes": os.path.getsize(abs_path),
}
)
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
with open(zip_path, "rb") as source_file:
encrypted_payload = _fernet().encrypt(source_file.read())
with open(encrypted_path, "wb") as encrypted_file:
encrypted_file.write(encrypted_payload)
deleted_files = 0
for _, _, abs_path, _ in files_to_archive:
try:
os.remove(abs_path)
deleted_files += 1
except OSError:
pass
try:
os.remove(zip_path)
except OSError:
pass
return {
"archive_created": True,
"archived_files": len(files_to_archive),
"deleted_files": deleted_files,
"archive_path": encrypted_path,
}
+1 -1
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
+2 -1
View File
@@ -11,4 +11,5 @@ pytz
requests
reportlab
python-barcode
openpyxl
openpyxl
cryptography
+117 -2
View File
@@ -12,6 +12,9 @@ defaults for the web application and helper modules.
"""
import os
import json
import atexit
from threading import Lock
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -55,6 +58,7 @@ DEFAULTS = {
'paths': {
'backups': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'backups'),
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
},
'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
@@ -98,10 +102,27 @@ def _get(conf, path, default):
return default
return cur
def _get_bool_env(name, default):
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in ('1', 'true', 'yes', 'on')
def _get_int_env(name, default):
value = os.getenv(name)
if value is None or not value.strip():
return int(default)
try:
return int(value)
except ValueError:
return int(default)
# Expose settings
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
DEBUG = _get(_conf, ['dbg'], DEFAULTS['debug'])
SECRET_KEY = str(_get(_conf, ['key'], DEFAULTS['secret_key']))
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
HOST = _get(_conf, ['host'], DEFAULTS['host'])
PORT = _get(_conf, ['port'], DEFAULTS['port'])
@@ -114,6 +135,13 @@ MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
MONGODB_CONNECT_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_CONNECT_TIMEOUT_MS', 5000)
MONGODB_SERVER_SELECTION_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SERVER_SELECTION_TIMEOUT_MS', 5000)
MONGODB_SOCKET_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SOCKET_TIMEOUT_MS', 30000)
MONGODB_MAX_CONNECTING = _get_int_env('INVENTAR_MONGODB_MAX_CONNECTING', 2)
# Scheduler
SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes'])
@@ -161,10 +189,12 @@ PREVIEW_SIZE = (int(PREVIEW_SIZE_LIST[0]), int(PREVIEW_SIZE_LIST[1])) if isinsta
BACKUP_FOLDER = _get(_conf, ['paths', 'backups'], DEFAULTS['paths']['backups'])
LOGS_FOLDER = _get(_conf, ['paths', 'logs'], DEFAULTS['paths']['logs'])
DELETED_ARCHIVE_FOLDER = _get(_conf, ['paths', 'deleted_archives'], DEFAULTS['paths']['deleted_archives'])
# Optional environment overrides for writable storage mounts.
BACKUP_FOLDER = os.getenv('INVENTAR_BACKUP_FOLDER', BACKUP_FOLDER)
LOGS_FOLDER = os.getenv('INVENTAR_LOGS_FOLDER', LOGS_FOLDER)
DELETED_ARCHIVE_FOLDER = os.getenv('INVENTAR_DELETED_ARCHIVE_FOLDER', DELETED_ARCHIVE_FOLDER)
# Normalize backup and logs paths to absolute paths (similar to upload folders) to avoid
# permission issues caused by relative paths resolving to unintended working dirs.
@@ -173,3 +203,88 @@ if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
if not os.path.isabs(DELETED_ARCHIVE_FOLDER):
DELETED_ARCHIVE_FOLDER = os.path.join(PROJECT_ROOT, DELETED_ARCHIVE_FOLDER)
# Optional key for field/file encryption at application level.
DATA_ENCRYPTION_KEY = os.getenv('INVENTAR_DATA_ENCRYPTION_KEY', '').strip()
_MONGO_CLIENT_CACHE = {}
_MONGO_CLIENT_LOCK = Lock()
class _MongoClientProxy:
def __init__(self, client):
self._client = client
def __getattr__(self, name):
return getattr(self._client, name)
def __getitem__(self, name):
return self._client[name]
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def close(self):
return None
def _close_cached_mongo_clients():
with _MONGO_CLIENT_LOCK:
clients = list(_MONGO_CLIENT_CACHE.values())
_MONGO_CLIENT_CACHE.clear()
for proxy in clients:
try:
proxy._client.close()
except Exception:
pass
atexit.register(_close_cached_mongo_clients)
def MongoClient(*args, **kwargs):
"""Return a process-local MongoDB client configured from this settings module."""
explicit_host = 'host' in kwargs
explicit_port = 'port' in kwargs
host = args[0] if len(args) >= 1 else kwargs.pop('host', MONGODB_HOST)
port = args[1] if len(args) >= 2 else kwargs.pop('port', MONGODB_PORT)
client_kwargs = {
'maxPoolSize': MONGODB_MAX_POOL_SIZE,
'minPoolSize': MONGODB_MIN_POOL_SIZE,
'maxIdleTimeMS': MONGODB_MAX_IDLE_TIME_MS,
'connectTimeoutMS': MONGODB_CONNECT_TIMEOUT_MS,
'serverSelectionTimeoutMS': MONGODB_SERVER_SELECTION_TIMEOUT_MS,
'socketTimeoutMS': MONGODB_SOCKET_TIMEOUT_MS,
'maxConnecting': MONGODB_MAX_CONNECTING,
'retryWrites': True,
'retryReads': True,
}
client_kwargs.update(kwargs)
if len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args
else:
mongo_args = (host, port)
cache_key = (
mongo_args,
tuple(sorted((key, repr(value)) for key, value in client_kwargs.items())),
)
with _MONGO_CLIENT_LOCK:
cached_client = _MONGO_CLIENT_CACHE.get(cache_key)
if cached_client is not None:
return cached_client
client = _PyMongoClient(*mongo_args, **client_kwargs)
cached_client = _MongoClientProxy(client)
_MONGO_CLIENT_CACHE[cache_key] = cached_client
return cached_client
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Defekte Items verwalten{% endblock %}
{% block content %}
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
<div>
<h1 style="margin:0;">Defekte Items</h1>
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
{% if library_module_enabled %}
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
{% endif %}
</div>
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
<table id="damage-table" style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
</tr>
</thead>
<tbody>
{% for item in damaged_items %}
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-weight:700;">{{ item.name }}</div>
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
Gemeldet von {{ item.latest_damage_by or '-' }}
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
{% if item.active_borrow %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
{% if item.active_borrow.End %}
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
{% endif %}
{% else %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not damaged_items %}
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
{% endif %}
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('damage-search');
if (searchInput) {
searchInput.addEventListener('input', function() {
const q = (searchInput.value || '').toLowerCase();
document.querySelectorAll('.damage-row').forEach(function(row) {
const text = row.getAttribute('data-search') || '';
row.style.display = text.includes(q) ? '' : 'none';
});
});
}
})();
function repairDamage(itemId, button) {
if (!itemId) return;
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
return;
}
button.disabled = true;
fetch('/mark_damage_repaired/' + itemId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (!data.success) {
throw new Error(data.message || 'Reparatur fehlgeschlagen');
}
window.location.reload();
})
.catch(function(err) {
button.disabled = false;
alert(err.message || 'Fehler beim Markieren als repariert.');
});
}
</script>
{% endblock %}
+470 -14
View File
@@ -7,7 +7,7 @@
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="en" data-module="{{ CURRENT_MODULE }}">
<html lang="de" data-module="{{ CURRENT_MODULE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
@@ -63,6 +63,9 @@
.navbar {
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.24);
background-color: var(--module-primary-color) !important;
position: sticky;
top: 0;
z-index: 1900;
}
.navbar-brand {
@@ -82,6 +85,65 @@
border-radius: 8px;
}
.navbar-nav .nav-link.nav-active {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.function-search-wrap {
display: flex;
align-items: center;
margin-right: 10px;
width: min(420px, 42vw);
}
.function-search-form {
width: 100%;
display: flex;
gap: 6px;
align-items: center;
}
.function-search-input {
width: 100%;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
padding: 8px 12px;
outline: none;
}
.function-search-input::placeholder {
color: rgba(255, 255, 255, 0.78);
}
.function-search-input:focus {
border-color: #93c5fd;
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.35);
background: rgba(255, 255, 255, 0.18);
}
.function-search-btn {
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
padding: 7px 12px;
font-weight: 700;
}
.function-search-btn:hover {
background: rgba(255, 255, 255, 0.26);
}
.quick-link-pill {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.08);
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
background-color: rgba(255, 255, 255, 0.12);
@@ -255,6 +317,27 @@
.nav-item.dropdown .dropdown-toggle {
padding: 8px 12px;
}
.navbar-toggler {
padding: 0.5rem 0.65rem;
border-width: 2px;
}
.function-search-wrap {
width: 100%;
margin: 8px 0 10px;
}
.navbar-nav .nav-item {
margin-right: 0;
margin-bottom: 6px;
}
.navbar-nav .nav-link {
min-height: 44px;
display: flex;
align-items: center;
}
/* Better touch targets for mobile */
.dropdown-toggle::after {
@@ -363,6 +446,7 @@
{% endblock %}
</head>
<body>
{% set current_path = request.path %}
<!-- Module Selector Bar -->
{% if 'username' in session %}
<div class="module-selector-bar" id="moduleBar">
@@ -396,16 +480,24 @@
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" 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>
{% if 'username' in session %}
<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>
</li>
<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>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" 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>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Mehr
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
@@ -418,6 +510,7 @@
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
@@ -434,12 +527,28 @@
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
@@ -462,16 +571,24 @@
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" 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>
{% if 'username' in session %}
<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>
</li>
<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>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" 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>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Mehr
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
@@ -482,8 +599,9 @@
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
@@ -499,12 +617,28 @@
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
@@ -597,6 +731,73 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
.notif-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 0.72rem;
font-weight: 800;
padding: 0 6px;
margin-left: 8px;
line-height: 1;
}
.user-menu-wrap {
position: relative;
}
.user-menu-btn {
position: relative;
}
.user-notification-dot {
position: absolute;
top: 6px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #dc2626;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
display: none;
}
.user-notification-dot.visible {
display: block;
}
.notification-toast {
position: fixed;
right: 16px;
bottom: 18px;
z-index: 2300;
max-width: 360px;
background: #0f172a;
color: #fff;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
box-shadow: 0 18px 35px rgba(2, 6, 23, 0.4);
padding: 12px 14px;
font-size: 0.9rem;
line-height: 1.35;
display: none;
}
.notification-toast strong {
display: block;
margin-bottom: 2px;
}
.notification-toast.show {
display: block;
}
</style>
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
<div class="cb-inner">
@@ -624,6 +825,38 @@
</div>
</div>
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
<span id="notification-toast-message"></span>
</div>
<datalist id="function-search-options">
<option value="Artikel"></option>
<option value="Meine Ausleihen"></option>
<option value="Benachrichtigungen"></option>
<option value="Tutorial"></option>
<option value="Impressum"></option>
<option value="Lizenz"></option>
{% if library_module_enabled %}
<option value="Bibliothek"></option>
<option value="Meine Medien"></option>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<option value="Hochladen"></option>
<option value="Ausleihen Verwaltung"></option>
<option value="Defekte Items"></option>
<option value="Filter verwalten"></option>
<option value="Orte verwalten"></option>
<option value="Audit Dashboard"></option>
<option value="Logs"></option>
<option value="Benutzer verwalten"></option>
<option value="Neuer Benutzer"></option>
{% if student_cards_module_enabled %}
<option value="Bibliotheksausweis"></option>
{% endif %}
{% endif %}
</datalist>
<script>
(function(){
function getCookie(name){
@@ -657,8 +890,231 @@
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast');
const notificationToastTitle = document.getElementById('notification-toast-title');
const notificationToastMessage = document.getElementById('notification-toast-message');
let lastUnreadCount = Number({{ unread_notification_count|default(0)|int }});
const loginHintKey = username ? ('inventarsystem_notification_login_hint_v1_' + username) : null;
const functionSearchEntries = [
{ label: 'Artikel', keywords: ['artikel', 'inventar', 'home'], url: {{ url_for('home')|tojson }} },
{ label: 'Meine Ausleihen', keywords: ['meine ausleihen', 'ausleihen', 'borrowed'], url: {{ url_for('my_borrowed_items')|tojson }} },
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
{ label: 'Lizenz', keywords: ['lizenz', 'license'], url: {{ url_for('license')|tojson }} },
{% if library_module_enabled %}
{ label: 'Bibliothek', keywords: ['bibliothek', 'medien'], url: {{ url_for('library_view')|tojson }} },
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
{ label: 'Hochladen', keywords: ['hochladen', 'upload'], url: {{ url_for('upload_admin')|tojson }} },
{ label: 'Ausleihen Verwaltung', keywords: ['ausleihen verwaltung', 'admin borrowings'], url: {{ url_for('admin_borrowings')|tojson }} },
{ label: 'Defekte Items', keywords: ['defekte items', 'defekt', 'schaden'], url: {{ url_for('admin_damaged_items')|tojson }} },
{ label: 'Filter verwalten', keywords: ['filter verwalten', 'filter'], url: {{ url_for('manage_filters')|tojson }} },
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
{ label: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|tojson }} },
{ label: 'Neuer Benutzer', keywords: ['neuer benutzer', 'register'], url: {{ url_for('register')|tojson }} },
{% if library_module_enabled %}
{ label: 'Bibliotheks-Ausleihen', keywords: ['bibliotheks ausleihen', 'library loans'], url: {{ url_for('library_loans_admin')|tojson }} },
{% endif %}
{% if student_cards_module_enabled %}
{ label: 'Bibliotheksausweis', keywords: ['bibliotheksausweis', 'student card'], url: {{ url_for('student_cards_admin')|tojson }} },
{% endif %}
{% endif %}
];
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[ä]/g, 'ae')
.replace(/[ö]/g, 'oe')
.replace(/[ü]/g, 'ue')
.replace(/[ß]/g, 'ss')
.trim();
}
function findFunctionRoute(rawValue) {
const input = normalizeSearchText(rawValue);
if (!input) {
return null;
}
let exact = null;
for (const entry of functionSearchEntries) {
const candidates = [entry.label].concat(entry.keywords || []);
for (const candidate of candidates) {
if (normalizeSearchText(candidate) === input) {
exact = entry;
break;
}
}
if (exact) {
break;
}
}
if (exact) {
return exact.url;
}
for (const entry of functionSearchEntries) {
const candidates = [entry.label].concat(entry.keywords || []);
for (const candidate of candidates) {
if (normalizeSearchText(candidate).includes(input) || input.includes(normalizeSearchText(candidate))) {
return entry.url;
}
}
}
return null;
}
function bindFunctionSearchForms() {
const forms = document.querySelectorAll('form[data-function-search="true"]');
forms.forEach(function(form) {
form.addEventListener('submit', function(event) {
event.preventDefault();
const input = form.querySelector('input[name="function_search"]');
const targetUrl = findFunctionRoute(input ? input.value : '');
if (targetUrl) {
window.location.href = targetUrl;
return;
}
showInAppNotification('Keine Funktion gefunden', 'Bitte Suche verfeinern, z.B. "Defekte Items" oder "Benachrichtigungen".');
});
});
}
bindFunctionSearchForms();
function maybeShowLoginNotificationHint() {
if (!username || !loginHintKey) {
return;
}
if (window.location.pathname === notificationsPagePath) {
return;
}
const alreadyShown = sessionStorage.getItem(loginHintKey);
if (alreadyShown) {
return;
}
if (lastUnreadCount > 0) {
const hintTitle = 'Neue Benachrichtigungen';
const hintMessage =
lastUnreadCount === 1
? 'Sie haben 1 neue Benachrichtigung. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.'
: 'Sie haben ' + lastUnreadCount + ' neue Benachrichtigungen. Im Nutzer-Menue finden Sie den Eintrag Benachrichtigungen.';
showInAppNotification(hintTitle, hintMessage);
}
sessionStorage.setItem(loginHintKey, 'shown');
}
maybeShowLoginNotificationHint();
function updateNotificationDots(unreadCount) {
const hasUnread = Number(unreadCount) > 0;
notificationButtons.forEach(function(btn) {
const dot = btn.querySelector('.user-notification-dot');
if (!dot) {
return;
}
if (hasUnread) {
dot.classList.add('visible');
} else {
dot.classList.remove('visible');
}
});
}
function showInAppNotification(title, message) {
if (!notificationToast || !notificationToastTitle || !notificationToastMessage) {
return;
}
notificationToastTitle.textContent = title || 'Neue Benachrichtigung';
notificationToastMessage.textContent = message || '';
notificationToast.classList.add('show');
window.setTimeout(function() {
notificationToast.classList.remove('show');
}, 5000);
}
function showBrowserNotification(title, message) {
if (!('Notification' in window)) {
showInAppNotification(title, message);
return;
}
if (Notification.permission === 'granted') {
new Notification(title || 'Neue Benachrichtigung', {
body: message || '',
});
return;
}
if (Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
if (permission === 'granted') {
new Notification(title || 'Neue Benachrichtigung', {
body: message || '',
});
} else {
showInAppNotification(title, message);
}
}).catch(function() {
showInAppNotification(title, message);
});
return;
}
showInAppNotification(title, message);
}
function pollNotificationStatus() {
if (!username) {
return;
}
fetch('/notifications/unread_status', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(function(response) {
if (!response.ok) {
throw new Error('status request failed');
}
return response.json();
})
.then(function(data) {
if (!data || !data.ok) {
return;
}
const unreadCount = Number(data.unread_count || 0);
updateNotificationDots(unreadCount);
if (unreadCount > lastUnreadCount && window.location.pathname !== notificationsPagePath) {
const latest = data.latest_unread || {};
showBrowserNotification(latest.title || 'Neue Nachricht', latest.message || 'Es gibt neue Benachrichtigungen.');
}
lastUnreadCount = unreadCount;
})
.catch(function() {
// Silent fail; polling retries automatically.
});
}
updateNotificationDots(lastUnreadCount);
if (username) {
window.setInterval(pollNotificationStatus, 30000);
}
function showOnboarding(){
if (onboardingOverlay) {
+9 -8
View File
@@ -14,20 +14,21 @@
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
<div class="impressum-section mb-4">
<h3 class="mb-3">Kontaktinformationen</h3>
<p><strong>Name:</strong> . ..</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
<p><strong>Telefon:</strong> +49 123 456789</p>
<address class="mb-0">
<p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
</address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 55 Abs. 2 RStV)</p>
<p>...<br>
<p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland</p>
@@ -37,7 +38,7 @@
<h3 class="mb-3">Haftungsausschluss</h3>
<h4 class="mb-2">Haftung für Inhalte</h4>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<h4 class="mb-2">Haftung für Links</h4>
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
-5
View File
@@ -321,11 +321,6 @@
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol>
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
<h3 style="color:#842029;">⚠️ Hinweis</h3>
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
einen Fachanwalt für Datenschutzrecht.</p>
</div>
</div>
</div><!-- /#pane-legal -->
+192 -11
View File
@@ -725,9 +725,74 @@
let allItems = [];
const MAIN_ITEMS_PAGE_SIZE = 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
return itemsContainer;
}
function updateMainItemsLoadingIndicator() {
const itemsContainer = ensureMainItemsLoadingIndicator();
if (!itemsContainer || !mainItemsLoadingIndicator) return;
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}`)
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -1009,7 +1074,7 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z)
// Start display from leftmost position on full reload only.
if (!append) {
itemsContainer.scrollLeft = 0;
}
@@ -1028,18 +1093,17 @@
rebuildFilter3Options();
}
if (data.has_more) {
const nextOffset = (data.offset || offset) + (data.count || pageItems.length);
const continueLoad = () => loadItems(nextOffset, true);
if ('requestIdleCallback' in window) {
requestIdleCallback(continueLoad, { timeout: 250 });
} else {
setTimeout(continueLoad, 0);
}
}
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
updateMainItemsLoadingIndicator();
setupMainItemsLazyLoading();
maybePrefetchMainItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainItemsLoadingMore = false;
mainItemsHasMore = false;
updateMainItemsLoadingIndicator();
if (append) {
return;
}
@@ -1049,6 +1113,72 @@
});
}
function ensureMainItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainItemsLoadingIndicator();
if (!mainItemsSentinel) {
mainItemsSentinel = document.createElement('div');
mainItemsSentinel.id = 'main-items-sentinel';
mainItemsSentinel.style.flex = '0 0 1px';
mainItemsSentinel.style.width = '1px';
mainItemsSentinel.style.minWidth = '1px';
mainItemsSentinel.style.height = '1px';
mainItemsSentinel.style.pointerEvents = 'none';
}
if (!mainItemsSentinel.parentNode) {
itemsContainer.appendChild(mainItemsSentinel);
} else {
itemsContainer.appendChild(mainItemsSentinel);
}
return itemsContainer;
}
function setupMainItemsLazyLoading() {
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
}
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainItemsLoadingMore || !mainItemsHasMore) {
return;
}
mainItemsLoadingMore = true;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
updateMainItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -2766,6 +2896,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
+203 -18
View File
@@ -323,6 +323,57 @@
scroll-behavior: smooth;
}
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card {
background-color: #fff;
border: 1px solid #ddd;
@@ -364,13 +415,17 @@
}
.bulk-delete-row {
display: flex;
display: none;
justify-content: flex-start;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bulk-delete-mode-active .bulk-delete-row {
display: flex;
}
.bulk-delete-toggle {
display: inline-flex;
align-items: center;
@@ -2966,9 +3021,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3;
// Update UI to show selected filters
updateSelectedFilters(1);
updateSelectedFilters(2);
updateSelectedFilters(3);
updateSelectedFiltersDisplay(1);
updateSelectedFiltersDisplay(2);
updateSelectedFiltersDisplay(3);
// We'll rebuild Filter 3 options after items are loaded
} catch (e) {
@@ -3005,6 +3060,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
bulkDeleteDrawerOpen = Boolean(open);
const drawer = document.getElementById('bulk-delete-drawer');
const toggle = document.getElementById('bulk-delete-drawer-toggle');
const itemsContainer = document.querySelector('#items-container');
if (drawer) {
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
@@ -3014,6 +3070,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
}
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
if (itemsContainer) {
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
}
}
function toggleBulkDeleteDrawer() {
@@ -3256,9 +3316,75 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Function to load items from server
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
let mainAdminLightMode = true; // Track if we're in light mode to gradually request full data
let mainAdminItemsObserver = null;
let mainAdminItemsSentinel = null;
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
return itemsContainer;
}
function updateMainAdminItemsLoadingIndicator() {
const itemsContainer = ensureMainAdminItemsLoadingIndicator();
if (!itemsContainer || !mainAdminItemsLoadingIndicator) return;
const shouldShow = mainAdminItemsHasMore || mainAdminItemsLoadingMore;
mainAdminItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainAdminItemsLoadingIndicator.classList.toggle('is-loading', mainAdminItemsLoadingMore);
const label = mainAdminItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainAdminItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
function maybePrefetchMainAdminItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}`)
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3270,6 +3396,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter3Values = new Set();
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
totalItemsInSystem = Number(data.total || totalItemsInSystem || pageItems.length || 0);
if (!append) {
itemsContainer.innerHTML = '';
}
@@ -3286,9 +3413,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
if (itemsIndicator) {
const shownCount = allItems.length;
const totalCount = Number(data.total || shownCount);
itemsIndicator.textContent = `Objekte im System: ${shownCount}${data.has_more ? ` / ${totalCount} (lädt...)` : ''}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem}${data.has_more ? ' (lädt...)' : ''}`;
}
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
@@ -3556,18 +3681,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
/* favorites render count removed */
if (data.has_more) {
const nextOffset = (data.offset || offset) + (data.count || pageItems.length);
const continueLoad = () => loadItems(nextOffset, true);
if ('requestIdleCallback' in window) {
requestIdleCallback(continueLoad, { timeout: 250 });
} else {
setTimeout(continueLoad, 0);
}
}
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainAdminItemsHasMore = Boolean(data.has_more);
updateMainAdminItemsLoadingIndicator();
setupMainAdminItemsLazyLoading();
maybePrefetchMainAdminItems();
})
.catch(error => {
console.error('Error fetching items:', error);
mainAdminItemsLoadingMore = false;
mainAdminItemsHasMore = false;
updateMainAdminItemsLoadingIndicator();
if (append) {
return;
}
@@ -3581,6 +3705,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function ensureMainAdminItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainAdminItemsLoadingIndicator();
if (!mainAdminItemsSentinel) {
mainAdminItemsSentinel = document.createElement('div');
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
mainAdminItemsSentinel.style.flex = '0 0 1px';
mainAdminItemsSentinel.style.width = '1px';
mainAdminItemsSentinel.style.minWidth = '1px';
mainAdminItemsSentinel.style.height = '1px';
mainAdminItemsSentinel.style.pointerEvents = 'none';
}
itemsContainer.appendChild(mainAdminItemsSentinel);
return itemsContainer;
}
function setupMainAdminItemsLazyLoading() {
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
}
if (!mainAdminItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
mainAdminItemsScrollPrefetchBound = true;
}
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
return;
}
mainAdminItemsLoadingMore = true;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
updateMainAdminItemsLoadingIndicator();
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
@@ -3675,7 +3860,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${allItems.length} | Angezeigt: ${visibleCount}`;
itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem || allItems.length} | Angezeigt: ${visibleCount}`;
}
if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) {
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Benachrichtigungen{% endblock %}
{% block content %}
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
<div>
<h1 style="margin:0;">Benachrichtigungen</h1>
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
</div>
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
</form>
</div>
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
{% if user_notifications %}
<div style="display:grid; gap:10px;">
{% for n in user_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
{% if n.type == 'damage_reported' %}
<div style="margin-top:8px;">
<a class="btn btn-sm btn-outline-danger" href="{{ url_for('admin_damaged_items') }}">Zu Defekte-Items Verwaltung</a>
</div>
{% endif %}
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% if is_admin_user %}
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
{% if admin_notifications %}
<div style="display:grid; gap:10px;">
{% for n in admin_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -124,12 +124,12 @@
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box">
<strong>📌 Was wird heruntergeladen?</strong>
<p>Eine druckfertige PDF mit:</p>
<p>✓ Alle Schülerausweise</p>
<p>✓ Alle Bibliotheksausweise</p>
<p>✓ Scanbare CODE128 Barcodes</p>
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
<p>✓ Professionelle Qualität</p>
+3 -3
View File
@@ -8,7 +8,7 @@
-->
{% extends "base.html" %}
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
{% block content %}
<style>
@@ -229,7 +229,7 @@
<div class="container">
<div class="student-card-header">
<div>
<h1>📚 Schülerausweise (Bibliotek)</h1>
<h1>📚 Bibliotheksausweise (Bibliotek)</h1>
</div>
<div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
@@ -342,7 +342,7 @@
</table>
{% else %}
<div class="empty-state">
<p>Keine Schülerausweise registriert.</p>
<p>Keine Bibliotheksausweise registriert.</p>
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
</div>
{% endif %}
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id):
+6 -1
View File
@@ -30,6 +30,8 @@ services:
dockerfile: Dockerfile
container_name: inventarsystem-app
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
mongodb:
condition: service_healthy
@@ -39,17 +41,19 @@ services:
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
volumes:
mongodb_data:
@@ -59,3 +63,4 @@ volumes:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
+2 -1
View File
@@ -10,4 +10,5 @@ pytz
requests
reportlab
python-barcode
openpyxl
openpyxl
cryptography