Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c2270842 | |||
| 20556f3500 | |||
| 7f1d616bb3 | |||
| 09cea7a0f8 | |||
| 061f975727 | |||
| 68f0efa296 | |||
| a27639a976 | |||
| 2f65fba3ae |
+382
-45
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ apscheduler
|
||||
python-dateutil
|
||||
pytz
|
||||
requests
|
||||
redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,7 @@ apscheduler
|
||||
python-dateutil
|
||||
pytz
|
||||
requests
|
||||
redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
|
||||
Reference in New Issue
Block a user