Compare commits

...

8 Commits

11 changed files with 758 additions and 98 deletions
+382 -45
View File
@@ -37,15 +37,24 @@ from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId
from urllib.parse import urlparse, urlunparse
import requests
import csv
import ipaddress
import os
import json
import datetime
import time
import traceback
import re
import socket
import io
import html
import logging
import secrets
import importlib
try:
redis = importlib.import_module('redis')
except Exception:
redis = None
# QR Code functionality deactivated
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
@@ -125,6 +134,14 @@ SSL_KEY = cfg.SSL_KEY
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media']
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
@@ -143,6 +160,45 @@ def _set_security_headers(response):
return response
def _get_csrf_token():
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
def _is_csrf_exempt_request():
return request.method in {'GET', 'HEAD', 'OPTIONS', 'TRACE'}
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400
flash(message, 'error')
return redirect(request.referrer or url_for('home'))
@app.before_request
def _enforce_csrf_protection():
if _is_csrf_exempt_request():
_get_csrf_token()
return None
expected_token = _get_csrf_token()
provided_token = (
request.headers.get('X-CSRFToken')
or request.headers.get('X-CSRF-Token')
or request.form.get('csrf_token')
or request.args.get('csrf_token')
)
if not provided_token or not secrets.compare_digest(provided_token, expected_token):
return _csrf_error_response()
return None
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -508,9 +564,154 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
'UpdatedAt': now,
}
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
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):
"""Fetch notifications visible to the current user, newest first."""
query = {
@@ -642,6 +843,7 @@ def inject_version():
"""Inject global template variables."""
is_admin = False
asset_version = _get_asset_version()
csrf_token = _get_csrf_token()
unread_notification_count = 0
if 'username' in session:
try:
@@ -665,6 +867,7 @@ def inject_version():
return {
'APP_VERSION': APP_VERSION,
'ASSET_VERSION': asset_version,
'csrf_token': csrf_token,
'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS,
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
@@ -1141,6 +1344,87 @@ def _excel_list(value):
return unique
def _load_tabular_upload(uploaded_file):
"""Load a CSV or XLSX upload and return header row plus data rows."""
filename = (getattr(uploaded_file, 'filename', '') or '').lower()
file_bytes = uploaded_file.read()
uploaded_file.stream.seek(0)
if filename.endswith('.csv'):
for encoding in ('utf-8-sig', 'utf-8', 'latin-1'):
try:
text = file_bytes.decode(encoding)
break
except Exception:
text = None
if text is None:
raise ValueError('CSV-Datei konnte nicht dekodiert werden.')
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=';,\t,|')
except Exception:
dialect = csv.excel
dialect.delimiter = ';' if sample.count(';') >= sample.count(',') else ','
reader = csv.reader(io.StringIO(text), dialect)
rows = [row for row in reader if any(str(cell).strip() for cell in row)]
if not rows:
raise ValueError('CSV-Datei enthält keine Daten.')
return rows[0], rows[1:]
try:
from openpyxl import load_workbook
except Exception as exc:
raise ValueError(f'Excel-Import benötigt openpyxl: {exc}') from exc
workbook = load_workbook(io.BytesIO(file_bytes), data_only=True, read_only=True)
sheet = workbook.active
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
workbook.close()
raise ValueError('Die Datei enthält keine Kopfzeile.')
data_rows = list(sheet.iter_rows(min_row=2, values_only=True))
workbook.close()
return header_row, data_rows
def _is_public_host(hostname):
"""Return True only for hosts that resolve to public IPs."""
if not hostname:
return False
hostname = hostname.strip().lower()
if hostname in {'localhost', '127.0.0.1', '::1'}:
return False
try:
resolved_infos = socket.getaddrinfo(hostname, None)
except Exception:
return False
public_seen = False
for info in resolved_infos:
address = info[4][0]
try:
ip_obj = ipaddress.ip_address(address)
except ValueError:
continue
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved or ip_obj.is_unspecified:
return False
public_seen = True
return public_seen
def _deny_if_unauthenticated_file_access():
"""Block file-serving routes unless a user is logged in."""
if 'username' not in session:
return Response('Forbidden', status=403)
return None
def _student_card_id_slug(value):
"""Build a compact identifier fragment from a name or class value."""
normalized = _normalize_excel_header(value)
@@ -1190,26 +1474,14 @@ def _upload_student_cards_excel():
return redirect(url_for('student_cards_admin'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith('.xlsx'):
flash('Nur .xlsx Dateien werden unterstützt.', 'error')
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
return redirect(url_for('student_cards_admin'))
try:
from openpyxl import load_workbook
except Exception:
flash('Excel-Import benötigt das Paket openpyxl. Bitte Abhängigkeiten aktualisieren.', 'error')
return redirect(url_for('student_cards_admin'))
try:
workbook = load_workbook(excel_file, data_only=True, read_only=True)
sheet = workbook.active
header_row, data_rows = _load_tabular_upload(excel_file)
except Exception as exc:
flash(f'Excel-Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for('student_cards_admin'))
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
flash('Die Excel-Datei enthält keine Kopfzeile.', 'error')
flash(f'Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for('student_cards_admin'))
header_map = {}
@@ -1220,10 +1492,10 @@ def _upload_student_cards_excel():
synonyms = {
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'student_name': ['student_name', 'schuelername', 'schülername', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
'first_name': ['vorname', 'first_name', 'firstname'],
'last_name': ['nachname', 'last_name', 'lastname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage', 'max_borrow_days'],
}
@@ -1264,7 +1536,7 @@ def _upload_student_cards_excel():
)
processed_rows = 0
for row_number, row_values in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
for row_number, row_values in enumerate(data_rows, start=2):
processed_rows += 1
if processed_rows > max_rows:
validation_errors.append((row_number, f'Maximal {max_rows} Zeilen pro Datei erlaubt'))
@@ -1396,26 +1668,14 @@ def _upload_excel_items(scope='inventory'):
return redirect(url_for(fallback_route))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith('.xlsx'):
flash('Nur .xlsx Dateien werden unterstützt.', 'error')
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
return redirect(url_for(fallback_route))
try:
from openpyxl import load_workbook
except Exception:
flash('Excel-Import benötigt das Paket openpyxl. Bitte Abhängigkeiten aktualisieren.', 'error')
return redirect(url_for(fallback_route))
try:
workbook = load_workbook(excel_file, data_only=True, read_only=True)
sheet = workbook.active
header_row, data_rows = _load_tabular_upload(excel_file)
except Exception as exc:
flash(f'Excel-Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for(fallback_route))
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
flash('Die Excel-Datei enthält keine Kopfzeile.', 'error')
flash(f'Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for(fallback_route))
header_map = {}
@@ -1504,7 +1764,7 @@ def _upload_excel_items(scope='inventory'):
planned_item_total = 0
row_limit_exceeded = False
for row_number, row_values in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
for row_number, row_values in enumerate(data_rows, start=2):
processed_rows += 1
if processed_rows > max_rows:
row_limit_exceeded = True
@@ -1711,6 +1971,10 @@ def uploaded_file(filename):
flask.Response: The requested file or placeholder image if not found
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Check production path first (deployed environment)
prod_path = "/opt/Inventarsystem/Web/uploads"
dev_path = app.config['UPLOAD_FOLDER']
@@ -1748,6 +2012,10 @@ def thumbnail_file(filename):
flask.Response: The requested thumbnail file or placeholder image if not found
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Check production path first
prod_path = "/var/Inventarsystem/Web/thumbnails"
dev_path = app.config['THUMBNAIL_FOLDER']
@@ -1783,6 +2051,10 @@ def preview_file(filename):
flask.Response: The requested preview file or placeholder image if not found
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Check production path first
prod_path = "/var/Inventarsystem/Web/previews"
dev_path = app.config['PREVIEW_FOLDER']
@@ -1854,6 +2126,10 @@ def catch_all_files(filename):
flask.Response: The requested file or placeholder image if not found
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Check if the file exists in any of our directories
possible_dirs = [
app.config['UPLOAD_FOLDER'],
@@ -1912,7 +2188,9 @@ def test_connection():
Returns:
dict: Status information including version and status code
"""
return {'status': 'success', 'message': 'Connection successful', 'version': __version__, 'status_code': 200}
if 'username' not in session or not us.check_admin(session['username']):
return {'status': 'forbidden'}, 403
return {'status': 'success', 'message': 'Connection successful', 'status_code': 200}
@app.route('/user_status')
@@ -4865,7 +5143,7 @@ def _soft_delete_item_groups(db, root_item_ids, username):
}
@app.route('/delete_item/<id>', methods=['POST', 'GET'])
@app.route('/delete_item/<id>', methods=['POST'])
def delete_item(id):
"""
Route for deleting inventory items.
@@ -7783,9 +8061,17 @@ def download_book_cover():
if not image_url:
return jsonify({"error": "No image URL provided"}), 400
parsed_url = urlparse(image_url)
if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
hostname = parsed_url.hostname or ''
if not _is_public_host(hostname):
return jsonify({"error": "Target host is not allowed"}), 400
# Download the image
response = requests.get(image_url, stream=True, timeout=10)
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
if response.status_code != 200:
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
@@ -7798,6 +8084,14 @@ def download_book_cover():
return jsonify({
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
}), 400
content_length = response.headers.get('Content-Length')
if content_length:
try:
if int(content_length) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
except ValueError:
pass
# Generate a fully unique filename using UUID
import uuid
@@ -7819,7 +8113,11 @@ def download_book_cover():
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
with open(filepath, 'wb') as f:
written = 0
for chunk in response.iter_content(chunk_size=8192):
written += len(chunk)
if written > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
f.write(chunk)
return jsonify({
@@ -7845,10 +8143,18 @@ def proxy_image():
url = request.args.get('url')
if not url:
return jsonify({"error": "No URL provided"}), 400
parsed_url = urlparse(url)
if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
hostname = parsed_url.hostname or ''
if not _is_public_host(hostname):
return jsonify({"error": "Target host is not allowed"}), 400
try:
# Fetch the image from the external source
response = requests.get(url, stream=True, timeout=5)
response = requests.get(url, stream=True, timeout=5, allow_redirects=False)
# Check if the request was successful
if response.status_code != 200:
@@ -7856,10 +8162,28 @@ def proxy_image():
# Get the content type
content_type = response.headers.get('Content-Type', 'image/jpeg')
if not content_type.lower().startswith('image/'):
return jsonify({"error": "Target URL did not return an image"}), 400
content_length = response.headers.get('Content-Length')
if content_length:
try:
if int(content_length) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
except ValueError:
pass
payload = bytearray()
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
payload.extend(chunk)
if len(payload) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
# Return the image data with appropriate headers
return Response(
response=response.content,
response=bytes(payload),
status=200,
headers={
'Content-Type': content_type
@@ -8118,13 +8442,15 @@ def mark_notification_read(notification_id):
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_one(
result = db['notifications'].update_one(
{'_id': ObjectId(notification_id)},
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
finally:
@@ -8159,13 +8485,15 @@ def mark_all_notifications_read():
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_many(
result = db['notifications'].update_many(
query,
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
finally:
@@ -8188,6 +8516,11 @@ def notifications_unread_status():
except Exception:
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
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -8231,11 +8564,15 @@ def notifications_unread_status():
'severity': latest_unread.get('Severity', 'info'),
}
return jsonify({
payload = {
'ok': True,
'unread_count': unread_count,
'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:
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
+1
View File
@@ -9,6 +9,7 @@ apscheduler
python-dateutil
pytz
requests
redis
reportlab
python-barcode
openpyxl
+6 -4
View File
@@ -155,10 +155,12 @@ select:focus {
@media (max-width: 900px) {
.container {
width: calc(100% - 18px);
padding: 14px;
margin: 10px auto;
border-radius: 10px;
width: 100%;
max-width: 100%;
padding: 12px 12px 18px;
margin: 0;
border-radius: 0;
box-sizing: border-box;
}
}
+217 -4
View File
@@ -14,6 +14,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -24,6 +25,74 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=ASSET_VERSION) }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.content : '';
if (!csrfToken) {
return;
}
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
function sameOrigin(url) {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (error) {
return false;
}
}
function ensureFormToken(form) {
const method = (form.getAttribute('method') || 'GET').toUpperCase();
if (safeMethods.has(method)) {
return;
}
const action = form.getAttribute('action') || window.location.href;
if (!sameOrigin(action)) {
return;
}
let tokenInput = form.querySelector('input[name="csrf_token"]');
if (!tokenInput) {
tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'csrf_token';
form.appendChild(tokenInput);
}
tokenInput.value = csrfToken;
}
document.addEventListener('submit', function (event) {
const form = event.target;
if (form && form.tagName === 'FORM') {
ensureFormToken(form);
}
}, true);
const originalFetch = window.fetch.bind(window);
window.fetch = function (resource, init) {
const options = init ? { ...init } : {};
const method = (options.method || 'GET').toUpperCase();
const targetUrl = resource instanceof Request ? resource.url : String(resource);
if (!safeMethods.has(method) && sameOrigin(targetUrl)) {
const headers = new Headers(resource instanceof Request ? resource.headers : undefined);
if (options.headers) {
new Headers(options.headers).forEach((value, key) => headers.set(key, value));
}
headers.set('X-CSRFToken', csrfToken);
headers.set('X-Requested-With', 'fetch');
options.headers = headers;
}
return originalFetch(resource, options);
};
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form[method="post"], form[method="POST"]').forEach(ensureFormToken);
});
})();
</script>
<style>
/* ===== MODULE DETECTION & SETUP ===== */
:root {
@@ -83,6 +152,51 @@
font-weight: 600;
padding: 0.6rem 0.85rem;
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 {
@@ -1164,6 +1278,27 @@
function initNavbarOverflow(navList, navOverflowAnchor) {
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() {
if (!navList) return [];
return Array.from(navList.children).filter(function(li){
@@ -1198,13 +1333,67 @@
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="overflowMenuToggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="overflowMenuToggle" id="overflowMenu"></ul>';
'<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="' + toggleId + '" id="' + menuId + '"></ul>';
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) {
const topLink = sourceItem.querySelector(':scope > a.nav-link');
if (!topLink) return;
@@ -1240,6 +1429,10 @@
const control = createOverflowControl();
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){
appendSourceToOverflowMenu(source, menu);
@@ -1256,6 +1449,8 @@
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
applyCompactMode();
if (window.innerWidth < 992) {
restoreAllNavItems();
return;
@@ -1267,7 +1462,10 @@
let candidates = collectTopLevelNavSources();
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';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
@@ -1277,7 +1475,10 @@
candidates = collectTopLevelNavSources();
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';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
@@ -1295,6 +1496,18 @@
adaptNavbarByWidth();
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
+46 -11
View File
@@ -774,8 +774,18 @@
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
const styles = window.getComputedStyle(itemsContainer);
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) {
return;
}
@@ -1142,6 +1152,12 @@
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
@@ -1172,7 +1188,7 @@
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
@@ -2942,6 +2958,22 @@
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 {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
@@ -3342,9 +3374,12 @@
/* Mobile-responsive styles for user interface */
.container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
border-radius: 0 !important;
box-sizing: border-box !important;
}
h1, h2 {
@@ -3414,7 +3449,7 @@
display: grid !important;
grid-template-columns: 1fr !important;
gap: 15px !important;
padding: 10px 0 !important;
padding: 10px 0 0 !important;
overflow-x: visible !important;
}
@@ -3431,12 +3466,12 @@
/* Modal improvements for mobile */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 20px !important;
padding: 16px !important;
}
/* Button improvements */
+67 -27
View File
@@ -369,6 +369,22 @@
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 {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
@@ -1221,9 +1237,11 @@
/* Mobile-responsive styles for admin interface */
.admin-content-container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
@@ -1347,11 +1365,12 @@
/* Modal improvements for admin */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 16px !important;
}
/* Admin card improvements */
@@ -1510,9 +1529,11 @@
/* Mobile-responsive styles for admin interface */
@media screen and (max-width: 768px) {
.admin-content-container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
}
h1, h2 {
@@ -1599,12 +1620,12 @@
/* Modal improvements for admin */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 20px !important;
padding: 16px !important;
}
/* Button improvements */
@@ -1792,9 +1813,11 @@
}
.admin-content-container {
width: 95%;
margin: 10px auto;
padding: 15px;
width: 100%;
max-width: 100%;
margin: 0;
padding: 12px 12px 18px;
box-sizing: border-box;
overflow-x: hidden;
}
@@ -1808,12 +1831,13 @@
/* Better modal sizing for mobile */
.modal-content {
width: 95% !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 10px auto !important;
margin: 8px auto !important;
max-height: 90vh !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
padding: 16px !important;
}
}
@@ -3366,8 +3390,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
const styles = window.getComputedStyle(itemsContainer);
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) {
return;
}
@@ -3609,9 +3643,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
<a href="{{ url_for('delete_item', id='') }}${item._id}" onclick="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
<button class="delete-button">Löschen</button>
</a>
<form method="POST" action="{{ url_for('delete_item', id='') }}${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
@@ -3729,6 +3763,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
@@ -3759,7 +3799,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
@@ -4420,9 +4460,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
<a href="/delete_item/${item._id}" onclick="return confirm('Sind Sie sicher?')">
<button class="delete-button">Löschen</button>
</a>
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
</div>
`;
+2 -2
View File
@@ -239,9 +239,9 @@
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliotheksausweise</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="student_cards_excel" accept=".xlsx" required>
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Ausweise importieren</button>
</form>
+4 -4
View File
@@ -714,9 +714,9 @@
{% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx" required>
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
@@ -724,9 +724,9 @@
{% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx" required>
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
+12 -1
View File
@@ -79,7 +79,18 @@ def check_password_strength(password):
Returns:
bool: True if password is strong enough, False otherwise
"""
if len(password) < 6:
if password is None:
return False
if len(password) < 12:
return False
has_lower = any(char.islower() for char in password)
has_upper = any(char.isupper() for char in password)
has_digit = any(char.isdigit() for char in password)
has_symbol = any(not char.isalnum() for char in password)
if not (has_lower and has_upper and has_digit and has_symbol):
return False
return True
+20
View File
@@ -24,6 +24,19 @@ services:
timeout: 5s
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:
build:
context: .
@@ -35,10 +48,16 @@ services:
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
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_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
@@ -64,3 +83,4 @@ volumes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
+1
View File
@@ -8,6 +8,7 @@ apscheduler
python-dateutil
pytz
requests
redis
reportlab
python-barcode
openpyxl