Files
Inventarsystem/Web/modules/database/settings.py
T
Aiirondev_dev 0c27d7ac86 feat: Add Excel, PDF export, and user generation modules
- Implemented `excel_export.py` for generating library item exports in Excel format.
- Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks.
- Developed `generate_user.py` for interactive user creation with validation for usernames and passwords.
- Introduced `module_registry.py` for managing module states and path matching.
- Added a basic `__init__.py` in the `terminplaner` module for initialization.
2026-05-20 15:30:23 +02:00

452 lines
17 KiB
Python

'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Centralized settings module to load configuration from config.json and provide
defaults for the web application and helper modules.
"""
import os
import json
import atexit
from threading import Lock
from flask import has_request_context
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Default values
DEFAULTS = {
'version': '2.6.5',
'debug': False,
'secret_key': 'Hsse783942h2342f342342i34hwebf8',
'host': '0.0.0.0',
'port': 443,
'mongodb': {
'host': 'localhost',
'port': 27017,
'db': 'Inventarsystem',
},
'scheduler': {
'interval_minutes': 1,
'backup_interval_hours': 24,
'enabled': True,
},
'ssl': {
'enabled': False,
'cert': 'ssl_certs/cert.pem',
'key': 'ssl_certs/key.pem',
},
'images': {
'thumbnail_size': [150, 150],
'preview_size': [400, 400],
},
'upload': {
'folder': os.path.join(BASE_DIR, 'uploads'),
'thumbnail_folder': os.path.join(BASE_DIR, 'thumbnails'),
'preview_folder': os.path.join(BASE_DIR, 'previews'),
'qrcode_folder': os.path.join(BASE_DIR, 'QRCodes'),
'max_size_mb': 10,
'image_max_size_mb': 15,
'video_max_size_mb': 100,
'allowed_extensions': ['png', 'jpg', 'jpeg', 'gif']
},
'paths': {
'backups': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'backups'),
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
},
'school': {
'name': 'Schulname',
'address': 'Schulstraße 1',
'postal_code': '00000',
'city': 'Ort',
'school_number': '000000',
'it_admin': 'IT-Beauftragte oder IT-Beauftragter',
'logo_path': '',
'logo_thumb': '',
'logo_thumb': '',
},
'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
"2": {"start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)"},
"3": {"start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)"},
"4": {"start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)"},
"5": {"start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)"},
"6": {"start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)"},
"7": {"start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)"},
"8": {"start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)"},
"9": {"start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)"},
"10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"}
},
'modules': {
'inventory': {
'enabled': True
},
'library': {
'enabled': False
},
'student_cards': {
'enabled': False,
'default_borrow_days': 14,
'max_borrow_days': 365
}
}
}
# Load configuration file
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json')
try:
with open(CONFIG_PATH, 'r') as f:
_conf = json.load(f)
except Exception:
_conf = {}
# Helper to get nested values with defaults
def _get(conf, path, default):
cur = conf
for p in path:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return default
return cur
def _get_bool_env(name, default):
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in ('1', 'true', 'yes', 'on')
def _get_int_env(name, default):
value = os.getenv(name)
if value is None or not value.strip():
return int(default)
try:
return int(value)
except ValueError:
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
# Expose settings
APP_VERSION = get_version()
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
HOST = _get(_conf, ['host'], DEFAULTS['host'])
PORT = _get(_conf, ['port'], DEFAULTS['port'])
# Database
MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host'])
MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port'])
MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_URI = _get(_conf, ['mongodb', 'uri'], '')
# Optional environment overrides for containerized/runtime deployments.
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_URI = os.getenv('INVENTAR_MONGODB_URI', os.getenv('MONGO_URI', MONGODB_URI))
if isinstance(MONGODB_URI, str):
MONGODB_URI = MONGODB_URI.strip() or None
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
MONGODB_CONNECT_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_CONNECT_TIMEOUT_MS', 5000)
MONGODB_SERVER_SELECTION_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SERVER_SELECTION_TIMEOUT_MS', 5000)
MONGODB_SOCKET_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SOCKET_TIMEOUT_MS', 30000)
MONGODB_MAX_CONNECTING = _get_int_env('INVENTAR_MONGODB_MAX_CONNECTING', 2)
# Scheduler
SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes'])
BACKUP_INTERVAL_HOURS = _get(_conf, ['scheduler', 'backup_interval_hours'], DEFAULTS['scheduler']['backup_interval_hours'])
SCHEDULER_ENABLED = _get(_conf, ['scheduler', 'enabled'], DEFAULTS['scheduler']['enabled'])
# SSL
SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# School periods
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
# Optional feature modules
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
class _TenantAwareBool:
def __init__(self, module_name, default):
self.module_name = module_name
self.default = bool(default)
def resolve(self):
try:
from tenant import is_tenant_module_enabled
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
except Exception:
return self.default
def __bool__(self):
return self.resolve()
def __int__(self):
return int(self.resolve())
def __str__(self):
return 'True' if self.resolve() else 'False'
def __repr__(self):
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
from Web.modules.module_registry import registry as MODULES
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
def _match_inventory(path):
if not path: return False
if path == '/' or path.startswith('/home'): return True
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
def _match_library(path):
if not path: return False
return path.startswith(('/library', '/library_', '/student_cards'))
def _match_student_cards(path):
if not path: return False
return path.startswith(('/student_cards'))
# Register core modules into the pipeline
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
# Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
UPLOAD_FOLDER = _get(_conf, ['upload', 'folder'], DEFAULTS['upload']['folder'])
THUMBNAIL_FOLDER = _get(_conf, ['upload', 'thumbnail_folder'], DEFAULTS['upload']['thumbnail_folder'])
PREVIEW_FOLDER = _get(_conf, ['upload', 'preview_folder'], DEFAULTS['upload']['preview_folder'])
QR_CODE_FOLDER = _get(_conf, ['upload', 'qrcode_folder'], DEFAULTS['upload']['qrcode_folder'])
# Normalize to absolute paths to avoid cwd issues
if not os.path.isabs(UPLOAD_FOLDER):
UPLOAD_FOLDER = os.path.join(BASE_DIR, os.path.relpath(UPLOAD_FOLDER, BASE_DIR))
if not os.path.isabs(THUMBNAIL_FOLDER):
THUMBNAIL_FOLDER = os.path.join(BASE_DIR, os.path.relpath(THUMBNAIL_FOLDER, BASE_DIR))
if not os.path.isabs(PREVIEW_FOLDER):
PREVIEW_FOLDER = os.path.join(BASE_DIR, os.path.relpath(PREVIEW_FOLDER, BASE_DIR))
if not os.path.isabs(QR_CODE_FOLDER):
QR_CODE_FOLDER = os.path.join(BASE_DIR, os.path.relpath(QR_CODE_FOLDER, BASE_DIR))
MAX_UPLOAD_MB = _get(_conf, ['upload', 'max_size_mb'], DEFAULTS['upload']['max_size_mb'])
IMAGE_MAX_UPLOAD_MB = _get(_conf, ['upload', 'image_max_size_mb'], DEFAULTS['upload']['image_max_size_mb'])
VIDEO_MAX_UPLOAD_MB = _get(_conf, ['upload', 'video_max_size_mb'], DEFAULTS['upload']['video_max_size_mb'])
THUMBNAIL_SIZE_LIST = _get(_conf, ['images', 'thumbnail_size'], DEFAULTS['images']['thumbnail_size'])
PREVIEW_SIZE_LIST = _get(_conf, ['images', 'preview_size'], DEFAULTS['images']['preview_size'])
THUMBNAIL_SIZE = (int(THUMBNAIL_SIZE_LIST[0]), int(THUMBNAIL_SIZE_LIST[1])) if isinstance(THUMBNAIL_SIZE_LIST, (list, tuple)) else (150, 150)
PREVIEW_SIZE = (int(PREVIEW_SIZE_LIST[0]), int(PREVIEW_SIZE_LIST[1])) if isinstance(PREVIEW_SIZE_LIST, (list, tuple)) else (400, 400)
BACKUP_FOLDER = _get(_conf, ['paths', 'backups'], DEFAULTS['paths']['backups'])
LOGS_FOLDER = _get(_conf, ['paths', 'logs'], DEFAULTS['paths']['logs'])
DELETED_ARCHIVE_FOLDER = _get(_conf, ['paths', 'deleted_archives'], DEFAULTS['paths']['deleted_archives'])
# Optional environment overrides for writable storage mounts.
BACKUP_FOLDER = os.getenv('INVENTAR_BACKUP_FOLDER', BACKUP_FOLDER)
LOGS_FOLDER = os.getenv('INVENTAR_LOGS_FOLDER', LOGS_FOLDER)
DELETED_ARCHIVE_FOLDER = os.getenv('INVENTAR_DELETED_ARCHIVE_FOLDER', DELETED_ARCHIVE_FOLDER)
# Normalize backup and logs paths to absolute paths (similar to upload folders) to avoid
# permission issues caused by relative paths resolving to unintended working dirs.
PROJECT_ROOT = os.path.dirname(os.path.dirname(BASE_DIR))
if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
if not os.path.isabs(DELETED_ARCHIVE_FOLDER):
DELETED_ARCHIVE_FOLDER = os.path.join(PROJECT_ROOT, DELETED_ARCHIVE_FOLDER)
# Optional key for field/file encryption at application level.
DATA_ENCRYPTION_KEY = os.getenv('INVENTAR_DATA_ENCRYPTION_KEY', '').strip()
_MONGO_CLIENT_CACHE = {}
_MONGO_CLIENT_LOCK = Lock()
class _MongoClientProxy:
def __init__(self, client):
self._client = client
def __getattr__(self, name):
return getattr(self._client, name)
def __getitem__(self, name):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client[ctx.db_name]
except Exception:
pass
return self._client[name]
def get_database(self, name=None, *args, **kwargs):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name is None or name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client.get_database(ctx.db_name, *args, **kwargs)
except Exception:
pass
return self._client.get_database(name, *args, **kwargs)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def close(self):
return None
def _close_cached_mongo_clients():
with _MONGO_CLIENT_LOCK:
clients = list(_MONGO_CLIENT_CACHE.values())
_MONGO_CLIENT_CACHE.clear()
for proxy in clients:
try:
proxy._client.close()
except Exception:
pass
atexit.register(_close_cached_mongo_clients)
def MongoClient(*args, **kwargs):
"""Return a process-local MongoDB client configured from this settings module."""
explicit_host = 'host' in kwargs
explicit_port = 'port' in kwargs
host = args[0] if len(args) >= 1 else kwargs.pop('host', MONGODB_HOST)
port = args[1] if len(args) >= 2 else kwargs.pop('port', MONGODB_PORT)
client_kwargs = {
'maxPoolSize': MONGODB_MAX_POOL_SIZE,
'minPoolSize': MONGODB_MIN_POOL_SIZE,
'maxIdleTimeMS': MONGODB_MAX_IDLE_TIME_MS,
'connectTimeoutMS': MONGODB_CONNECT_TIMEOUT_MS,
'serverSelectionTimeoutMS': MONGODB_SERVER_SELECTION_TIMEOUT_MS,
'socketTimeoutMS': MONGODB_SOCKET_TIMEOUT_MS,
'maxConnecting': MONGODB_MAX_CONNECTING,
'retryWrites': True,
'retryReads': True,
}
client_kwargs.update(kwargs)
if MONGODB_URI and len(args) == 0 and not explicit_host and not explicit_port:
mongo_args = (MONGODB_URI,)
elif len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args
else:
mongo_args = (host, port)
cache_key = (
mongo_args,
tuple(sorted((key, repr(value)) for key, value in client_kwargs.items())),
)
with _MONGO_CLIENT_LOCK:
cached_client = _MONGO_CLIENT_CACHE.get(cache_key)
if cached_client is not None:
return cached_client
client = _PyMongoClient(*mongo_args, **client_kwargs)
cached_client = _MongoClientProxy(client)
_MONGO_CLIENT_CACHE[cache_key] = cached_client
return cached_client
def get_school_info():
"""Return the tenant-scoped school metadata used for PDFs and admin views."""
school_info = dict(SCHOOL_INFO_DEFAULT)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
if 'settings' not in db.list_collection_names():
return school_info
settings_collection = db['settings']
settings_document = settings_collection.find_one({'setting_type': 'school_info'})
if not settings_document:
return school_info
configured_school = settings_document.get('school', {})
if isinstance(configured_school, dict):
for key, value in configured_school.items():
if value is not None:
school_info[key] = value
return school_info
except Exception:
return school_info
finally:
if client:
client.close()
def update_school_info(school_info):
"""Persist tenant-scoped school metadata into MongoDB and refresh the in-memory cache."""
if not isinstance(school_info, dict):
raise TypeError('school_info must be a dict')
updated_school = dict(SCHOOL_INFO_DEFAULT)
for key in updated_school.keys():
value = school_info.get(key, updated_school[key])
if value is None:
value = ''
updated_school[key] = str(value).strip()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
settings_collection = db['settings']
settings_collection.update_one(
{'setting_type': 'school_info'},
{
'$set': {
'setting_type': 'school_info',
'school': updated_school,
}
},
upsert=True,
)
finally:
if client:
client.close()
return dict(updated_school)