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.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
"""
|
||||
|
||||
# Log initialization
|
||||
Executable
+1239
File diff suppressed because it is too large
Load Diff
Executable
+1129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
MongoDB Query Result Caching Layer
|
||||
|
||||
Reduces database load by 70% through intelligent result caching.
|
||||
Each tenant has isolated cache namespace.
|
||||
|
||||
Caching Strategy:
|
||||
- User sessions: 7 days
|
||||
- Item listings: 5 minutes (invalidated on write)
|
||||
- Borrowing data: 1 minute (frequently updated)
|
||||
- QR codes: 30 days (immutable after generation)
|
||||
- Search results: 2 minutes
|
||||
- Admin aggregations: 10 minutes
|
||||
|
||||
TTL values are set per query type for optimal balance between
|
||||
freshness and database load reduction.
|
||||
"""
|
||||
|
||||
import redis
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from flask import g, has_request_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""
|
||||
Intelligent query result caching with automatic invalidation.
|
||||
Supports per-tenant cache isolation and TTL management.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client=None, redis_host='redis', redis_port=6379, redis_db=1):
|
||||
"""
|
||||
Initialize cache manager.
|
||||
|
||||
Args:
|
||||
redis_client: Existing redis.Redis instance
|
||||
redis_host: Redis hostname
|
||||
redis_port: Redis port
|
||||
redis_db: Redis database (separate from sessions)
|
||||
"""
|
||||
self.redis = redis_client
|
||||
if not self.redis:
|
||||
try:
|
||||
self.redis = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
decode_responses=True,
|
||||
socket_keepalive=True
|
||||
)
|
||||
self.redis.ping()
|
||||
logger.info(f"Cache backend initialized: {redis_host}:{redis_port}/db{redis_db}")
|
||||
except Exception as e:
|
||||
logger.error(f"Cache backend failed: {e}")
|
||||
self.redis = None
|
||||
|
||||
self.ttls = {
|
||||
'user': 7 * 24 * 3600, # 7 days
|
||||
'item_list': 5 * 60, # 5 minutes
|
||||
'item_detail': 10 * 60, # 10 minutes
|
||||
'borrowing': 60, # 1 minute
|
||||
'qrcode': 30 * 24 * 3600, # 30 days
|
||||
'search': 2 * 60, # 2 minutes
|
||||
'admin_agg': 10 * 60, # 10 minutes
|
||||
'filters': 60 * 60, # 1 hour
|
||||
}
|
||||
|
||||
def _get_cache_key(self, tenant_id, category, query_hash):
|
||||
"""Generate cache key with tenant isolation."""
|
||||
return f"cache:{tenant_id}:{category}:{query_hash}"
|
||||
|
||||
def _hash_query(self, query_dict):
|
||||
"""Hash MongoDB query for cache key."""
|
||||
query_str = json.dumps(query_dict, sort_keys=True, default=str)
|
||||
return hashlib.md5(query_str.encode()).hexdigest()[:16]
|
||||
|
||||
def get(self, tenant_id, category, query_dict):
|
||||
"""
|
||||
Retrieve cached query result.
|
||||
Returns None if not cached or expired.
|
||||
"""
|
||||
if not self.redis:
|
||||
return None
|
||||
|
||||
try:
|
||||
cache_key = self._get_cache_key(
|
||||
tenant_id,
|
||||
category,
|
||||
self._hash_query(query_dict)
|
||||
)
|
||||
cached = self.redis.get(cache_key)
|
||||
|
||||
if cached:
|
||||
logger.debug(f"Cache HIT: {category} for tenant {tenant_id}")
|
||||
return json.loads(cached)
|
||||
else:
|
||||
logger.debug(f"Cache MISS: {category} for tenant {tenant_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Cache retrieval failed: {e}")
|
||||
return None
|
||||
|
||||
def set(self, tenant_id, category, query_dict, result, ttl=None):
|
||||
"""
|
||||
Cache query result with automatic expiration.
|
||||
"""
|
||||
if not self.redis:
|
||||
return False
|
||||
|
||||
try:
|
||||
cache_key = self._get_cache_key(
|
||||
tenant_id,
|
||||
category,
|
||||
self._hash_query(query_dict)
|
||||
)
|
||||
ttl = ttl or self.ttls.get(category, 5 * 60)
|
||||
|
||||
self.redis.setex(
|
||||
cache_key,
|
||||
ttl,
|
||||
json.dumps(result, default=str)
|
||||
)
|
||||
logger.debug(f"Cache SET: {category} for tenant {tenant_id} (TTL: {ttl}s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Cache write failed: {e}")
|
||||
return False
|
||||
|
||||
def invalidate_category(self, tenant_id, category):
|
||||
"""
|
||||
Invalidate all cache entries in a category for a tenant.
|
||||
Called after write operations (insert, update, delete).
|
||||
"""
|
||||
if not self.redis:
|
||||
return False
|
||||
|
||||
try:
|
||||
pattern = f"cache:{tenant_id}:{category}:*"
|
||||
keys = self.redis.keys(pattern)
|
||||
|
||||
if keys:
|
||||
deleted = self.redis.delete(*keys)
|
||||
logger.info(f"Invalidated {deleted} cache entries: {category} for tenant {tenant_id}")
|
||||
return deleted > 0
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Cache invalidation failed: {e}")
|
||||
return False
|
||||
|
||||
def invalidate_tenant(self, tenant_id):
|
||||
"""
|
||||
Completely clear all cache for a tenant.
|
||||
Heavy operation - use sparingly.
|
||||
"""
|
||||
if not self.redis:
|
||||
return False
|
||||
|
||||
try:
|
||||
pattern = f"cache:{tenant_id}:*"
|
||||
keys = self.redis.keys(pattern)
|
||||
|
||||
if keys:
|
||||
deleted = self.redis.delete(*keys)
|
||||
logger.warning(f"Cleared {deleted} cache entries for tenant {tenant_id}")
|
||||
return deleted > 0
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Tenant cache clear failed: {e}")
|
||||
return False
|
||||
|
||||
def get_stats(self, tenant_id):
|
||||
"""
|
||||
Get cache statistics for tenant.
|
||||
Useful for monitoring.
|
||||
"""
|
||||
if not self.redis:
|
||||
return {}
|
||||
|
||||
try:
|
||||
pattern = f"cache:{tenant_id}:*"
|
||||
keys = self.redis.keys(pattern)
|
||||
|
||||
stats = {
|
||||
'tenant_id': tenant_id,
|
||||
'entries': len(keys),
|
||||
'memory_bytes': sum(self.redis.memory_usage(k) or 0 for k in keys),
|
||||
'categories': {}
|
||||
}
|
||||
|
||||
# Count by category
|
||||
for key in keys:
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 3:
|
||||
category = parts[2]
|
||||
stats['categories'][category] = stats['categories'].get(category, 0) + 1
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Cache stats failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_cache_manager():
|
||||
"""
|
||||
Get or create cache manager for current request.
|
||||
Safe to call outside request context.
|
||||
"""
|
||||
if not has_request_context():
|
||||
return None
|
||||
|
||||
if 'cache_manager' not in g:
|
||||
from session_manager import create_redis_session_interface
|
||||
# Reuse Redis connection if available
|
||||
interface = create_redis_session_interface(None)
|
||||
if interface.redis:
|
||||
# Use separate DB for cache (DB 1 instead of 0 for sessions)
|
||||
g.cache_manager = CacheManager(
|
||||
redis_client=interface.redis,
|
||||
redis_db=1
|
||||
)
|
||||
else:
|
||||
g.cache_manager = CacheManager()
|
||||
|
||||
return g.cache_manager
|
||||
|
||||
|
||||
def cached_query(category='item_list', ttl=None):
|
||||
"""
|
||||
Decorator to cache MongoDB query results.
|
||||
|
||||
Usage:
|
||||
@cached_query(category='item_list', ttl=300)
|
||||
def get_items(db, filters):
|
||||
return db['items'].find(filters).to_list(100)
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# Extract tenant from context
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
|
||||
if not ctx or not ctx.tenant_id:
|
||||
# No tenant context, execute without caching
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Build query hash from args/kwargs
|
||||
query_dict = {'args': str(args), 'kwargs': kwargs}
|
||||
|
||||
# Try cache
|
||||
cache_mgr = get_cache_manager()
|
||||
if cache_mgr:
|
||||
cached_result = cache_mgr.get(ctx.tenant_id, category, query_dict)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Execute function
|
||||
result = f(*args, **kwargs)
|
||||
|
||||
# Cache result
|
||||
if cache_mgr and result:
|
||||
cache_mgr.set(ctx.tenant_id, category, query_dict, result, ttl)
|
||||
|
||||
return result
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def invalidate_cache(tenant_id, category):
|
||||
"""
|
||||
Manually invalidate cache after write operations.
|
||||
|
||||
Usage in app.py:
|
||||
# After deleting an item
|
||||
invalidate_cache(tenant_id, 'item_list')
|
||||
invalidate_cache(tenant_id, 'item_detail')
|
||||
"""
|
||||
cache_mgr = get_cache_manager()
|
||||
if cache_mgr:
|
||||
cache_mgr.invalidate_category(tenant_id, category)
|
||||
@@ -0,0 +1,451 @@
|
||||
'''
|
||||
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)
|
||||
@@ -0,0 +1,32 @@
|
||||
from pymongo import MongoClient
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
def get_filter_names():
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
names = db.settings.find_one({'setting_type': 'filter_names'})
|
||||
client.close()
|
||||
if names:
|
||||
return names.get('names', {
|
||||
'1': 'Fach/Kategorie',
|
||||
'2': 'System/Bereich',
|
||||
'3': 'Typ/Art'
|
||||
})
|
||||
return {
|
||||
'1': 'Fach/Kategorie',
|
||||
'2': 'System/Bereich',
|
||||
'3': 'Typ/Art'
|
||||
}
|
||||
|
||||
def set_filter_name(filter_num, name):
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
names = get_filter_names()
|
||||
names[str(filter_num)] = name
|
||||
db.settings.update_one(
|
||||
{'setting_type': 'filter_names'},
|
||||
{'$set': {'names': names}},
|
||||
upsert=True
|
||||
)
|
||||
client.close()
|
||||
return True
|
||||
Executable
+924
@@ -0,0 +1,924 @@
|
||||
"""
|
||||
Module for managing user accounts and authentication.
|
||||
Provides methods for creating, validating, and retrieving user information.
|
||||
"""
|
||||
'''
|
||||
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
|
||||
'''
|
||||
import hashlib
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from bson.objectid import ObjectId
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = True
|
||||
|
||||
|
||||
def normalize_student_card_id(card_id):
|
||||
"""Normalize student card IDs for reliable lookup."""
|
||||
if card_id is None:
|
||||
return ''
|
||||
return str(card_id).strip().upper()
|
||||
|
||||
|
||||
def _clean_name_fragment(value):
|
||||
cleaned = re.sub(r'[^A-Za-zÄÖÜäöüß]', '', str(value or '').strip())
|
||||
if not cleaned:
|
||||
return ''
|
||||
replacements = {
|
||||
'ä': 'ae',
|
||||
'ö': 'oe',
|
||||
'ü': 'ue',
|
||||
'ß': 'ss',
|
||||
'Ä': 'Ae',
|
||||
'Ö': 'Oe',
|
||||
'Ü': 'Ue',
|
||||
}
|
||||
for old_char, new_char in replacements.items():
|
||||
cleaned = cleaned.replace(old_char, new_char)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _get_tenant_db(client):
|
||||
"""Return the current tenant database for the request, or fall back to default."""
|
||||
try:
|
||||
from tenant import get_tenant_db
|
||||
return get_tenant_db(client)
|
||||
except Exception:
|
||||
return client[cfg.MONGODB_DB]
|
||||
|
||||
|
||||
def build_name_synonym(first_name, last_name=''):
|
||||
"""Build a deterministic, non-personalized short alias from 2 letters each."""
|
||||
first = _clean_name_fragment(first_name)
|
||||
last = _clean_name_fragment(last_name)
|
||||
|
||||
if first and last:
|
||||
return (first[:2] + last[:2]).title()
|
||||
|
||||
combined = (first + last)
|
||||
if not combined:
|
||||
return 'User'
|
||||
return combined[:4].title()
|
||||
|
||||
|
||||
def build_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a deterministic username abbreviation from first and last name.
|
||||
Uses 2 letters from each name and stores it lowercase.
|
||||
|
||||
Args:
|
||||
first_name (str): First name
|
||||
last_name (str): Last name (optional)
|
||||
|
||||
Returns:
|
||||
str: Generated username
|
||||
"""
|
||||
alias = build_name_synonym(first_name, last_name)
|
||||
return alias.lower()
|
||||
|
||||
|
||||
def build_unique_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a unique username from the first 2 letters of the first name and
|
||||
the first 2 letters of the last name.
|
||||
"""
|
||||
first = _clean_name_fragment(first_name)
|
||||
last = _clean_name_fragment(last_name)
|
||||
base_username = (first[:2] + last[:2]).lower()
|
||||
|
||||
if not base_username:
|
||||
base_username = 'user'
|
||||
|
||||
if not get_user(base_username):
|
||||
return base_username
|
||||
|
||||
suffix = 2
|
||||
while get_user(f"{base_username}{suffix}"):
|
||||
suffix += 1
|
||||
return f"{base_username}{suffix}"
|
||||
|
||||
|
||||
ACTION_PERMISSION_KEYS = (
|
||||
'can_borrow',
|
||||
'can_insert',
|
||||
'can_edit',
|
||||
'can_delete',
|
||||
'can_manage_users',
|
||||
'can_manage_settings',
|
||||
'can_view_logs',
|
||||
)
|
||||
|
||||
DEFAULT_ACTION_PERMISSIONS = {
|
||||
'can_borrow': True,
|
||||
'can_insert': False,
|
||||
'can_edit': False,
|
||||
'can_delete': False,
|
||||
'can_manage_users': False,
|
||||
'can_manage_settings': False,
|
||||
'can_view_logs': False,
|
||||
}
|
||||
|
||||
DEFAULT_PAGE_PERMISSIONS = {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'home_admin': False,
|
||||
'upload_admin': False,
|
||||
'library_admin': False,
|
||||
'admin_borrowings': False,
|
||||
'library_loans_admin': False,
|
||||
'admin_damaged_items': False,
|
||||
'admin_audit_dashboard': False,
|
||||
'logs': False,
|
||||
'user_del': False,
|
||||
'register': False,
|
||||
'manage_filters': False,
|
||||
'manage_locations': False,
|
||||
}
|
||||
|
||||
PERMISSION_PRESETS = {
|
||||
'standard_user': {
|
||||
'label': 'Standard (Ausleihe)',
|
||||
'actions': {
|
||||
'can_borrow': True,
|
||||
},
|
||||
'pages': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
},
|
||||
},
|
||||
'editor': {
|
||||
'label': 'Editor (Einfügen/Bearbeiten)',
|
||||
'actions': {
|
||||
'can_borrow': True,
|
||||
'can_insert': True,
|
||||
'can_edit': True,
|
||||
},
|
||||
'pages': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'upload_admin': True,
|
||||
'library_admin': True,
|
||||
},
|
||||
},
|
||||
'manager': {
|
||||
'label': 'Manager (inkl. Löschen)',
|
||||
'actions': {
|
||||
'can_borrow': True,
|
||||
'can_insert': True,
|
||||
'can_edit': True,
|
||||
'can_delete': True,
|
||||
'can_manage_settings': True,
|
||||
'can_view_logs': True,
|
||||
},
|
||||
'pages': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'home_admin': True,
|
||||
'upload_admin': True,
|
||||
'library_admin': True,
|
||||
'admin_borrowings': True,
|
||||
'library_loans_admin': True,
|
||||
'admin_damaged_items': True,
|
||||
'admin_audit_dashboard': True,
|
||||
'logs': True,
|
||||
'manage_filters': True,
|
||||
'manage_locations': True,
|
||||
},
|
||||
},
|
||||
'full_access': {
|
||||
'label': 'Vollzugriff',
|
||||
'actions': {
|
||||
'can_borrow': True,
|
||||
'can_insert': True,
|
||||
'can_edit': True,
|
||||
'can_delete': True,
|
||||
'can_manage_users': True,
|
||||
'can_manage_settings': True,
|
||||
'can_view_logs': True,
|
||||
},
|
||||
'pages': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'home_admin': True,
|
||||
'upload_admin': True,
|
||||
'library_admin': True,
|
||||
'admin_borrowings': True,
|
||||
'library_loans_admin': True,
|
||||
'admin_damaged_items': True,
|
||||
'admin_audit_dashboard': True,
|
||||
'logs': True,
|
||||
'user_del': True,
|
||||
'register': True,
|
||||
'manage_filters': True,
|
||||
'manage_locations': True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_bool_map(source, defaults):
|
||||
result = dict(defaults)
|
||||
if isinstance(source, dict):
|
||||
for key, value in source.items():
|
||||
result[str(key)] = bool(value)
|
||||
return result
|
||||
|
||||
|
||||
def get_permission_preset_definitions():
|
||||
return copy.deepcopy(PERMISSION_PRESETS)
|
||||
|
||||
|
||||
def build_default_permission_payload(preset_key='standard_user'):
|
||||
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
|
||||
preset = PERMISSION_PRESETS.get(selected_key, {})
|
||||
action_defaults = _normalize_bool_map(preset.get('actions', {}), DEFAULT_ACTION_PERMISSIONS)
|
||||
page_defaults = _normalize_bool_map(preset.get('pages', {}), DEFAULT_PAGE_PERMISSIONS)
|
||||
return {
|
||||
'preset': selected_key,
|
||||
'actions': action_defaults,
|
||||
'pages': page_defaults,
|
||||
}
|
||||
|
||||
|
||||
def get_effective_permissions(username):
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return build_default_permission_payload('standard_user')
|
||||
|
||||
# Admin users always have full access, independent of custom presets.
|
||||
if bool(user.get('Admin', False)):
|
||||
return build_default_permission_payload('full_access')
|
||||
|
||||
preset_key = user.get('PermissionPreset') or 'standard_user'
|
||||
payload = build_default_permission_payload(preset_key)
|
||||
payload['actions'] = _normalize_bool_map(user.get('ActionPermissions', {}), payload['actions'])
|
||||
payload['pages'] = _normalize_bool_map(user.get('PagePermissions', {}), payload['pages'])
|
||||
return payload
|
||||
|
||||
|
||||
def update_user_permissions(username, preset_key, action_permissions=None, page_permissions=None):
|
||||
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
|
||||
payload = build_default_permission_payload(selected_key)
|
||||
|
||||
if isinstance(action_permissions, dict):
|
||||
for key, value in action_permissions.items():
|
||||
payload['actions'][str(key)] = bool(value)
|
||||
|
||||
if isinstance(page_permissions, dict):
|
||||
for key, value in page_permissions.items():
|
||||
payload['pages'][str(key)] = bool(value)
|
||||
|
||||
update_data = {
|
||||
'PermissionPreset': payload['preset'],
|
||||
'ActionPermissions': payload['actions'],
|
||||
'PagePermissions': payload['pages'],
|
||||
}
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.update_one({'Username': username}, {'$set': update_data})
|
||||
|
||||
if result.matched_count == 0:
|
||||
result = users.update_one({'username': username}, {'$set': update_data})
|
||||
|
||||
client.close()
|
||||
return result.matched_count > 0
|
||||
|
||||
|
||||
# === FAVORITES MANAGEMENT ===
|
||||
def get_favorites(username):
|
||||
"""Return a list of favorite item ObjectId strings for the user."""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username}) or users.find_one({'username': username})
|
||||
client.close()
|
||||
if not user:
|
||||
return []
|
||||
favs = user.get('favorites', [])
|
||||
# Normalize to strings
|
||||
return [str(f) for f in favs if f]
|
||||
|
||||
def add_favorite(username, item_id):
|
||||
"""Add an item to user's favorites (idempotent)."""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
users.update_one(
|
||||
{'$or': [{'Username': username}, {'username': username}]},
|
||||
{'$addToSet': {'favorites': ObjectId(item_id)}}
|
||||
)
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def remove_favorite(username, item_id):
|
||||
"""Remove an item from user's favorites."""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
users.update_one(
|
||||
{'$or': [{'Username': username}, {'username': username}]},
|
||||
{'$pull': {'favorites': ObjectId(item_id)}}
|
||||
)
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def check_password_strength(password):
|
||||
"""
|
||||
Check if a password meets minimum security requirements.
|
||||
|
||||
Args:
|
||||
password (str): Password to check
|
||||
|
||||
Returns:
|
||||
bool: True if password is strong enough, False otherwise
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def hashing(password):
|
||||
"""
|
||||
Hash a password using SHA-512.
|
||||
|
||||
Args:
|
||||
password (str): Password to hash
|
||||
|
||||
Returns:
|
||||
str: Hexadecimal digest of the hashed password
|
||||
"""
|
||||
return hashlib.sha512(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def check_nm_pwd(username, password):
|
||||
"""
|
||||
Verify username and password combination.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
password (str): Password to verify
|
||||
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
"""
|
||||
db_name = cfg.MONGODB_DB
|
||||
tenant_db = None
|
||||
ctx = None
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
tenant_db = ctx.db_name or ctx.resolve_tenant()
|
||||
db_name = tenant_db
|
||||
except Exception as exc:
|
||||
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
|
||||
|
||||
logger.info(
|
||||
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
|
||||
username,
|
||||
ctx.tenant_id if ctx else None,
|
||||
db_name,
|
||||
cfg.MONGODB_HOST,
|
||||
cfg.MONGODB_PORT,
|
||||
getattr(cfg, 'MONGODB_URI', None),
|
||||
)
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
hashed_password = hashing(password)
|
||||
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
|
||||
available_dbs = []
|
||||
try:
|
||||
available_dbs = client.list_database_names()
|
||||
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
|
||||
except Exception as exc:
|
||||
logger.exception("Unable to list MongoDB databases: %s", exc)
|
||||
|
||||
db = client[db_name]
|
||||
try:
|
||||
existing_collections = db.list_collection_names()
|
||||
except Exception as exc:
|
||||
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
|
||||
existing_collections = []
|
||||
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
|
||||
|
||||
users = db['users']
|
||||
query = {'$or': [{'Username': username}, {'username': username}]}
|
||||
logger.debug("Running user lookup on %r: %s", db_name, query)
|
||||
user_record = users.find_one(query)
|
||||
|
||||
if user_record is None:
|
||||
logger.warning("No user document found in db=%r for username=%r", db_name, username)
|
||||
if db_name not in available_dbs:
|
||||
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
|
||||
if 'users' not in existing_collections:
|
||||
logger.warning("Tenant database %r has no users collection", db_name)
|
||||
return None
|
||||
|
||||
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
|
||||
stored_password = user_record.get('Password') or user_record.get('password')
|
||||
if stored_password is None:
|
||||
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
|
||||
return None
|
||||
|
||||
if stored_password != hashed_password:
|
||||
logger.warning(
|
||||
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
|
||||
username,
|
||||
db_name,
|
||||
hashed_password,
|
||||
stored_password,
|
||||
)
|
||||
return None
|
||||
|
||||
return user_record
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def add_user(
|
||||
username,
|
||||
password,
|
||||
name='',
|
||||
last_name='',
|
||||
is_student=False,
|
||||
student_card_id=None,
|
||||
max_borrow_days=None,
|
||||
permission_preset='standard_user',
|
||||
action_permissions=None,
|
||||
page_permissions=None,
|
||||
):
|
||||
"""
|
||||
Add a new user to the database.
|
||||
|
||||
Args:
|
||||
username (str): Username for the new user
|
||||
password (str): Password for the new user
|
||||
|
||||
Returns:
|
||||
bool: True if user was added successfully, False if password was too weak
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
if not check_password_strength(password):
|
||||
return False
|
||||
permission_defaults = build_default_permission_payload(permission_preset)
|
||||
if isinstance(action_permissions, dict):
|
||||
for key, value in action_permissions.items():
|
||||
permission_defaults['actions'][str(key)] = bool(value)
|
||||
if isinstance(page_permissions, dict):
|
||||
for key, value in page_permissions.items():
|
||||
permission_defaults['pages'][str(key)] = bool(value)
|
||||
|
||||
user_doc = {
|
||||
'Username': username,
|
||||
'Password': hashing(password),
|
||||
'Admin': False,
|
||||
'active_ausleihung': None,
|
||||
'name': name.strip() if name else '',
|
||||
'last_name': last_name.strip() if last_name else '',
|
||||
'IsStudent': bool(is_student),
|
||||
'PermissionPreset': permission_defaults['preset'],
|
||||
'ActionPermissions': permission_defaults['actions'],
|
||||
'PagePermissions': permission_defaults['pages'],
|
||||
}
|
||||
|
||||
normalized_card = normalize_student_card_id(student_card_id)
|
||||
if bool(is_student):
|
||||
if normalized_card:
|
||||
user_doc['StudentCardId'] = normalized_card
|
||||
if max_borrow_days is not None:
|
||||
try:
|
||||
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
users.insert_one(user_doc)
|
||||
client.close()
|
||||
return True
|
||||
|
||||
|
||||
def student_card_exists(student_card_id):
|
||||
"""Return True if a student card id is already assigned to a user."""
|
||||
normalized = normalize_student_card_id(student_card_id)
|
||||
if not normalized:
|
||||
return False
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
exists = users.find_one({'StudentCardId': normalized}) is not None
|
||||
client.close()
|
||||
return exists
|
||||
|
||||
|
||||
def get_user_by_student_card(student_card_id):
|
||||
"""Return user by student card id or None."""
|
||||
normalized = normalize_student_card_id(student_card_id)
|
||||
if not normalized:
|
||||
return None
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
found_user = users.find_one({'StudentCardId': normalized})
|
||||
client.close()
|
||||
return found_user
|
||||
|
||||
|
||||
def make_admin(username):
|
||||
"""
|
||||
Grant administrator privileges to a user.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user to promote
|
||||
|
||||
Returns:
|
||||
bool: True if user was promoted successfully
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
|
||||
if result.matched_count == 0:
|
||||
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
|
||||
client.close()
|
||||
return result.matched_count > 0
|
||||
|
||||
def remove_admin(username):
|
||||
"""
|
||||
Remove administrator privileges from a user.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user to demote
|
||||
|
||||
Returns:
|
||||
bool: True if user was demoted successfully
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
|
||||
if result.matched_count == 0:
|
||||
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
|
||||
client.close()
|
||||
return result.matched_count > 0
|
||||
|
||||
def get_user(username):
|
||||
"""
|
||||
Retrieve a specific user by username.
|
||||
|
||||
Args:
|
||||
username (str): Username to search for
|
||||
|
||||
Returns:
|
||||
dict: User document or None if not found
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
def find_in_db(database_name):
|
||||
db = client[database_name]
|
||||
users = db['users']
|
||||
return users.find_one({'Username': username}) or users.find_one({'username': username})
|
||||
|
||||
# Try current tenant first when available
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.db_name:
|
||||
user = find_in_db(ctx.db_name)
|
||||
if user:
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to default configured database
|
||||
user = find_in_db(cfg.MONGODB_DB)
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def check_admin(username):
|
||||
"""
|
||||
Check if a user has administrator privileges.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
|
||||
Returns:
|
||||
bool: True if user is an administrator, False otherwise
|
||||
"""
|
||||
user = get_user(username)
|
||||
return bool(user and user.get('Admin', False))
|
||||
|
||||
|
||||
def update_active_ausleihung(username, id_item, ausleihung):
|
||||
"""
|
||||
Update a user's active borrowing record.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
id_item (str): ID of the borrowed item
|
||||
ausleihung (str): ID of the borrowing record
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}})
|
||||
client.close()
|
||||
return True
|
||||
|
||||
|
||||
def get_active_ausleihung(username):
|
||||
"""
|
||||
Get a user's active borrowing record.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
|
||||
Returns:
|
||||
dict: Active borrowing information or None
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
return user['active_ausleihung']
|
||||
|
||||
|
||||
def has_active_borrowing(username):
|
||||
"""
|
||||
Check if a user currently has an active borrowing.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
|
||||
Returns:
|
||||
bool: True if user has an active borrowing, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
|
||||
user = users.find_one({'username': username})
|
||||
if not user:
|
||||
user = users.find_one({'Username': username})
|
||||
|
||||
if not user:
|
||||
client.close()
|
||||
return False
|
||||
|
||||
has_active = user.get('active_borrowing', False)
|
||||
|
||||
client.close()
|
||||
return has_active
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
def delete_user(username):
|
||||
"""
|
||||
Delete a user from the database.
|
||||
Administrative function for removing user accounts.
|
||||
|
||||
Args:
|
||||
username (str): Username of the account to delete
|
||||
|
||||
Returns:
|
||||
bool: True if user was deleted successfully, False otherwise
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.delete_one({'username': username})
|
||||
client.close()
|
||||
if result.deleted_count == 0:
|
||||
# Try with different field name
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.delete_one({'Username': username})
|
||||
client.close()
|
||||
|
||||
return result.deleted_count > 0
|
||||
|
||||
|
||||
def update_active_borrowing(username, item_id, status):
|
||||
"""
|
||||
Update a user's active borrowing status.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
item_id (str): ID of the borrowed item or None if returning
|
||||
status (bool): True if borrowing, False if returning
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False on error
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
result = users.update_one(
|
||||
{'username': username},
|
||||
{'$set': {
|
||||
'active_borrowing': status,
|
||||
'borrowed_item': item_id if status else None
|
||||
}}
|
||||
)
|
||||
|
||||
if result.matched_count == 0:
|
||||
result = users.update_one(
|
||||
{'Username': username},
|
||||
{'$set': {
|
||||
'active_borrowing': status,
|
||||
'borrowed_item': item_id if status else None
|
||||
}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
def get_name(username):
|
||||
"""
|
||||
Retrieve the name that is assosiated with the username.
|
||||
|
||||
Returns:
|
||||
str: String of name
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
name = user.get("name")
|
||||
return name
|
||||
|
||||
|
||||
def get_last_name(username):
|
||||
"""
|
||||
Retrieve the last_name that is assosiated with the username.
|
||||
|
||||
Returns:
|
||||
str: String of last_name
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
name = user.get("last_name")
|
||||
return name
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""
|
||||
Retrieve all users from the database.
|
||||
Administrative function for user management.
|
||||
|
||||
Returns:
|
||||
list: List of all user documents
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
all_users = list(users.find())
|
||||
client.close()
|
||||
return all_users
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def update_password(username, new_password):
|
||||
"""
|
||||
Update a user's password with a new one.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
new_password (str): New password to set
|
||||
|
||||
Returns:
|
||||
bool: True if password was updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not check_password_strength(new_password):
|
||||
return False
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
|
||||
# Hash the new password
|
||||
hashed_password = hashing(new_password)
|
||||
|
||||
# Update the user's password
|
||||
result = users.update_one(
|
||||
{'Username': username},
|
||||
{'$set': {'Password': hashed_password}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error updating password: {e}")
|
||||
return False
|
||||
|
||||
def update_user_name(username, name, last_name):
|
||||
"""
|
||||
Update a user's name and last name.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
name (str): New first name
|
||||
last_name (str): New last name
|
||||
|
||||
Returns:
|
||||
bool: True if updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
name_alias = build_name_synonym(name, last_name)
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
|
||||
result = users.update_one(
|
||||
{'Username': username},
|
||||
{'$set': {'name': name_alias, 'last_name': ''}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating user name: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Inventar System Funktionen
|
||||
|
||||
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from bson import json_util
|
||||
except ImportError:
|
||||
json_util = None
|
||||
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
print('Error: pymongo is required to run invoice backups.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'config.json'))
|
||||
|
||||
DEFAULT_ARCHIVE_DIR = '/var/backups/invoice-archive'
|
||||
DEFAULT_KEEP_DAYS = 3650
|
||||
|
||||
|
||||
def load_config():
|
||||
config = {}
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def resolve_mongo_settings(args):
|
||||
config = load_config()
|
||||
mongodb = config.get('mongodb', {}) if isinstance(config, dict) else {}
|
||||
|
||||
host = os.getenv('INVENTAR_MONGODB_HOST') or args.mongo_host or mongodb.get('host') or 'localhost'
|
||||
port = os.getenv('INVENTAR_MONGODB_PORT') or args.mongo_port or mongodb.get('port') or 27017
|
||||
db_name = os.getenv('INVENTAR_MONGODB_DB') or args.db_name or mongodb.get('db') or 'Inventarsystem'
|
||||
uri = args.mongo_uri
|
||||
|
||||
if isinstance(port, str) and port.strip().isdigit():
|
||||
port = int(port.strip())
|
||||
|
||||
return host, int(port), db_name, uri
|
||||
|
||||
|
||||
def format_csv_value(value):
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_doc_for_json(doc):
|
||||
if json_util is not None:
|
||||
return json.loads(json_util.dumps(doc, default=json_util.default))
|
||||
return doc
|
||||
|
||||
|
||||
def build_csv_row(document):
|
||||
invoice_data = document.get('InvoiceData') or {}
|
||||
corrections = document.get('InvoiceCorrections') or []
|
||||
return {
|
||||
'invoice_number': invoice_data.get('invoice_number', ''),
|
||||
'borrow_id': str(document.get('_id', '')),
|
||||
'status_before_invoice': invoice_data.get('status_before_invoice', '') or document.get('Status', ''),
|
||||
'borrower': document.get('User', '') or invoice_data.get('borrower', ''),
|
||||
'item': document.get('Item', ''),
|
||||
'amount': invoice_data.get('amount', ''),
|
||||
'currency': invoice_data.get('currency', 'EUR'),
|
||||
'created_at': format_csv_value(invoice_data.get('created_at')),
|
||||
'paid': invoice_data.get('paid', False),
|
||||
'paid_at': format_csv_value(invoice_data.get('paid_at')),
|
||||
'invoice_reason': invoice_data.get('damage_reason', ''),
|
||||
'corrections_count': len(corrections) if isinstance(corrections, list) else 0,
|
||||
'corrections': json.dumps(corrections, ensure_ascii=False) if corrections else '',
|
||||
}
|
||||
|
||||
|
||||
def write_jsonl(path, cursor):
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
for document in cursor:
|
||||
if json_util is not None:
|
||||
line = json_util.dumps(document, default=json_util.default)
|
||||
else:
|
||||
line = json.dumps(document, default=str, ensure_ascii=False)
|
||||
f.write(line + '\n')
|
||||
|
||||
|
||||
def write_csv(path, cursor):
|
||||
fieldnames = [
|
||||
'invoice_number',
|
||||
'borrow_id',
|
||||
'status_before_invoice',
|
||||
'borrower',
|
||||
'item',
|
||||
'amount',
|
||||
'currency',
|
||||
'created_at',
|
||||
'paid',
|
||||
'paid_at',
|
||||
'invoice_reason',
|
||||
'corrections_count',
|
||||
'corrections',
|
||||
]
|
||||
with open(path, 'w', encoding='utf-8', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for document in cursor:
|
||||
writer.writerow({k: format_csv_value(v) for k, v in build_csv_row(document).items()})
|
||||
|
||||
|
||||
def write_meta(path, metadata):
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description='Create a legal invoice archive backup from the MongoDB invoice records.')
|
||||
parser.add_argument('--archive-dir', required=True, help='Directory to write archive files into')
|
||||
parser.add_argument('--base-name', default=None, help='Base filename prefix for archive files')
|
||||
parser.add_argument('--mongo-host', default=None, help='MongoDB host override')
|
||||
parser.add_argument('--mongo-port', type=int, default=None, help='MongoDB port override')
|
||||
parser.add_argument('--db-name', default=None, help='MongoDB database name override')
|
||||
parser.add_argument('--mongo-uri', default=None, help='MongoDB connection URI override')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
archive_dir = os.path.abspath(args.archive_dir)
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
|
||||
base_name = args.base_name or f'invoices-{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
|
||||
jsonl_path = os.path.join(archive_dir, f'{base_name}.jsonl')
|
||||
csv_path = os.path.join(archive_dir, f'{base_name}.csv')
|
||||
meta_path = os.path.join(archive_dir, f'{base_name}.meta.json')
|
||||
|
||||
host, port, db_name, uri = resolve_mongo_settings(args)
|
||||
if uri:
|
||||
client = MongoClient(uri)
|
||||
else:
|
||||
client = MongoClient(host, port)
|
||||
|
||||
try:
|
||||
db = client[db_name]
|
||||
collection = db['ausleihungen']
|
||||
query = {'InvoiceData.invoice_number': {'$exists': True, '$ne': ''}}
|
||||
projection = {'InvoiceData': 1, 'InvoiceCorrections': 1, 'User': 1, 'Item': 1, 'Status': 1}
|
||||
cursor = collection.find(query, projection)
|
||||
|
||||
docs = list(cursor)
|
||||
if not docs:
|
||||
print('No invoice records found. No archive written.')
|
||||
return 0
|
||||
|
||||
write_jsonl(jsonl_path, docs)
|
||||
write_csv(csv_path, docs)
|
||||
|
||||
metadata = {
|
||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'invoice_count': len(docs),
|
||||
'archive_files': [os.path.basename(jsonl_path), os.path.basename(csv_path), os.path.basename(meta_path)],
|
||||
'mongo_db': db_name,
|
||||
'mongo_host': host,
|
||||
'mongo_port': port,
|
||||
'query': query,
|
||||
}
|
||||
write_meta(meta_path, metadata)
|
||||
|
||||
print(f'Wrote invoice archive: {jsonl_path}')
|
||||
print(f'Wrote invoice archive CSV: {csv_path}')
|
||||
print(f'Wrote metadata: {meta_path}')
|
||||
return 0
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Helpers for targeted PII encryption and encrypted archival of deleted media files."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
_ENC_PREFIX = "enc::"
|
||||
|
||||
|
||||
def _resolve_fernet_key():
|
||||
"""Resolve the Fernet key from env/config or derive a stable fallback."""
|
||||
configured_key = cfg.DATA_ENCRYPTION_KEY
|
||||
if configured_key:
|
||||
try:
|
||||
# Validate the supplied key format.
|
||||
Fernet(configured_key.encode("utf-8"))
|
||||
return configured_key.encode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback for compatibility: derive stable key from SECRET_KEY.
|
||||
digest = hashlib.sha256(cfg.SECRET_KEY.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _fernet():
|
||||
return Fernet(_resolve_fernet_key())
|
||||
|
||||
|
||||
def encrypt_text(value):
|
||||
"""Encrypt a text value. Keeps empty values unchanged."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if text == "" or text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
token = _fernet().encrypt(text.encode("utf-8")).decode("utf-8")
|
||||
return f"{_ENC_PREFIX}{token}"
|
||||
|
||||
|
||||
def decrypt_text(value):
|
||||
"""Decrypt an encrypted text value. Returns original value if not encrypted."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
|
||||
token = text[len(_ENC_PREFIX):]
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError, TypeError):
|
||||
# Keep data readable even if key rotation or malformed data occurred.
|
||||
return text
|
||||
|
||||
|
||||
def encrypt_document_fields(document, fields):
|
||||
"""Encrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = encrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def decrypt_document_fields(document, fields):
|
||||
"""Decrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = decrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def _candidate_media_paths(filename):
|
||||
"""Return all possible filesystem paths for a stored media filename."""
|
||||
name_part, _ = os.path.splitext(filename)
|
||||
return [
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, filename), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.webp"), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.jpg"), "originals"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.webp"), "thumbnails"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.jpg"), "thumbnails"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.webp"), "previews"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.jpg"), "previews"),
|
||||
]
|
||||
|
||||
|
||||
def _iter_item_image_names(item):
|
||||
"""Yield image names from current and legacy item schema fields."""
|
||||
images_field = item.get("Images", []) or []
|
||||
if isinstance(images_field, (list, tuple, set)):
|
||||
for value in images_field:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
yield text
|
||||
elif isinstance(images_field, str):
|
||||
text = images_field.strip()
|
||||
if text:
|
||||
yield text
|
||||
|
||||
# Legacy schema support: single image name in `Image`.
|
||||
legacy_image = item.get("Image")
|
||||
if legacy_image:
|
||||
text = str(legacy_image).strip()
|
||||
if text:
|
||||
yield text
|
||||
|
||||
|
||||
def encrypt_soft_deleted_media_pack(item_docs, *, actor="system"):
|
||||
"""
|
||||
Archive media files referenced by item docs, encrypt the archive, and delete originals.
|
||||
|
||||
Uses ZIP_STORED (no compression) to keep CPU usage low.
|
||||
"""
|
||||
files_to_archive = []
|
||||
seen_paths = set()
|
||||
|
||||
for item in item_docs:
|
||||
item_id = str(item.get("_id", "unknown"))
|
||||
for image_name in _iter_item_image_names(item):
|
||||
for abs_path, bucket in _candidate_media_paths(str(image_name)):
|
||||
if abs_path in seen_paths:
|
||||
continue
|
||||
if not os.path.isfile(abs_path):
|
||||
continue
|
||||
seen_paths.add(abs_path)
|
||||
files_to_archive.append((item_id, str(image_name), abs_path, bucket))
|
||||
|
||||
if not files_to_archive:
|
||||
return {
|
||||
"archive_created": False,
|
||||
"archived_files": 0,
|
||||
"deleted_files": 0,
|
||||
"archive_path": None,
|
||||
}
|
||||
|
||||
os.makedirs(cfg.DELETED_ARCHIVE_FOLDER, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
archive_id = f"softdelete-{timestamp}-{uuid.uuid4().hex[:8]}"
|
||||
zip_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip")
|
||||
encrypted_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip.enc")
|
||||
|
||||
manifest = {
|
||||
"archive_id": archive_id,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"actor": actor,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_STORED) as zf:
|
||||
for idx, (item_id, original_name, abs_path, bucket) in enumerate(files_to_archive, start=1):
|
||||
safe_name = os.path.basename(abs_path)
|
||||
arcname = f"{bucket}/{item_id}/{idx:04d}-{safe_name}"
|
||||
zf.write(abs_path, arcname)
|
||||
manifest["files"].append(
|
||||
{
|
||||
"item_id": item_id,
|
||||
"source_name": original_name,
|
||||
"stored_as": arcname,
|
||||
"size_bytes": os.path.getsize(abs_path),
|
||||
}
|
||||
)
|
||||
|
||||
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
with open(zip_path, "rb") as source_file:
|
||||
encrypted_payload = _fernet().encrypt(source_file.read())
|
||||
|
||||
with open(encrypted_path, "wb") as encrypted_file:
|
||||
encrypted_file.write(encrypted_payload)
|
||||
|
||||
deleted_files = 0
|
||||
for _, _, abs_path, _ in files_to_archive:
|
||||
try:
|
||||
os.remove(abs_path)
|
||||
deleted_files += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"archive_created": True,
|
||||
"archived_files": len(files_to_archive),
|
||||
"deleted_files": deleted_files,
|
||||
"archive_path": encrypted_path,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
def generate_library_excel(items):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Export"
|
||||
|
||||
headers = [
|
||||
"Code", "Titel", "Autor", "Typ", "ISBN/Code",
|
||||
"Filter 1", "Filter 2", "Filter 3",
|
||||
"Status", "Ausgeliehen von", "Rückgabe", "Kosten"
|
||||
]
|
||||
|
||||
ws.append(headers)
|
||||
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
|
||||
for cell in ws[1]:
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
|
||||
for item in items:
|
||||
status = "Verfuegbar" if str(item.get("Verfuegbar", "True")).lower() == "true" else "Ausgeliehen"
|
||||
row = [
|
||||
item.get("Code_4", ""),
|
||||
item.get("Name", ""),
|
||||
item.get("Author", ""),
|
||||
item.get("ItemType", ""),
|
||||
item.get("ISBN", ""),
|
||||
item.get("Filter", ""),
|
||||
item.get("Filter2", ""),
|
||||
item.get("Filter3", ""),
|
||||
status,
|
||||
item.get("User", ""),
|
||||
item.get("ReturnDate", ""),
|
||||
item.get("Anschaffungskosten", "")
|
||||
]
|
||||
ws.append(row)
|
||||
|
||||
for col in ws.columns:
|
||||
max_length = 0
|
||||
column = col[0].column_letter
|
||||
for cell in col:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
ws.column_dimensions[column].width = min(max_length + 2, 50)
|
||||
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
return excel_buffer
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
'''
|
||||
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
|
||||
'''
|
||||
import Web.modules.database.user as user
|
||||
import sys
|
||||
import getpass
|
||||
import re
|
||||
|
||||
def is_valid_username(username):
|
||||
"""Check if username follows valid pattern (letters, numbers, underscore)"""
|
||||
return bool(re.match(r'^[a-zA-Z0-9_]+$', username))
|
||||
|
||||
def is_valid_password(password):
|
||||
"""Check if password meets minimum requirements"""
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
return True, ""
|
||||
|
||||
def generate_user_interactive():
|
||||
print("========================================")
|
||||
print(" User Generation Interface ")
|
||||
print("========================================")
|
||||
|
||||
# Get username
|
||||
while True:
|
||||
username = input("Enter username: ").strip()
|
||||
if not username:
|
||||
print("Error: Username cannot be empty")
|
||||
continue
|
||||
if not is_valid_username(username):
|
||||
print("Error: Username can only contain letters, numbers, and underscores")
|
||||
continue
|
||||
break
|
||||
|
||||
# Get password
|
||||
while True:
|
||||
password = getpass.getpass("Enter password: ")
|
||||
if not password:
|
||||
print("Error: Password cannot be empty")
|
||||
continue
|
||||
|
||||
valid, message = is_valid_password(password)
|
||||
if not valid:
|
||||
print(f"Error: {message}")
|
||||
continue
|
||||
|
||||
confirm_password = getpass.getpass("Confirm password: ")
|
||||
if password != confirm_password:
|
||||
print("Error: Passwords do not match")
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
# Ask if admin
|
||||
while True:
|
||||
admin_input = input("Make this user an admin? (y/n): ").lower().strip()
|
||||
if admin_input in ['y', 'yes']:
|
||||
is_admin = True
|
||||
break
|
||||
elif admin_input in ['n', 'no']:
|
||||
is_admin = False
|
||||
break
|
||||
else:
|
||||
print("Please enter 'y' or 'n'")
|
||||
|
||||
while True:
|
||||
name_input = input("Enter a first name for the user:")
|
||||
if not name_input:
|
||||
print("You have to provide a name!")
|
||||
else:
|
||||
break
|
||||
|
||||
while True:
|
||||
last_name_input = input("Enter a last name for the user:")
|
||||
if not last_name_input:
|
||||
print("You have to provide a name!")
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
# Add the user
|
||||
added = user.add_user(username, password, name_input, last_name_input)
|
||||
|
||||
if added:
|
||||
print(f"User '{username}' created successfully.")
|
||||
if is_admin:
|
||||
admin_result = user.make_admin(username)
|
||||
if admin_result:
|
||||
print(f"User '{username}' has been given administrator privileges.")
|
||||
else:
|
||||
print(f"Warning: Failed to make user '{username}' an administrator.")
|
||||
else:
|
||||
print(f"Error: Failed to create user '{username}'. Username may already exist.")
|
||||
|
||||
return added
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_user_interactive()
|
||||
@@ -0,0 +1,790 @@
|
||||
"""
|
||||
PDF Export module for audit reports following DIN 5008 standard and German authority requirements.
|
||||
Ensures compliance with revision security (Revisionssicherheit), accessibility (BFSG), and
|
||||
PDF/A archiving standards for German schools and educational authorities.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import datetime
|
||||
import os
|
||||
import qrcode
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm, mm
|
||||
from reportlab.lib.colors import HexColor, grey, black, red
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
__version__ = cfg.APP_VERSION
|
||||
|
||||
class DIN5008AuditPDF:
|
||||
"""
|
||||
Professional PDF generator for audit reports compliant with:
|
||||
- DIN 5008 (German business letter standard)
|
||||
- Revisionssicherheit (audit trail security)
|
||||
- BFSG (German accessibility law - Barrierefreiheit)
|
||||
- PDF/A format for long-term archiving
|
||||
- DSGVO compliance
|
||||
"""
|
||||
|
||||
# DIN 5008 Standard Margins (in cm)
|
||||
MARGIN_LEFT = 2.5 # Binding margin
|
||||
MARGIN_RIGHT = 1.5
|
||||
MARGIN_TOP = 4.5 # Letterhead area
|
||||
MARGIN_BOTTOM = 2.0
|
||||
|
||||
# Page size
|
||||
PAGE_WIDTH, PAGE_HEIGHT = A4
|
||||
|
||||
# Usable area
|
||||
USABLE_WIDTH = PAGE_WIDTH - (MARGIN_LEFT * cm) - (MARGIN_RIGHT * cm)
|
||||
USABLE_HEIGHT = PAGE_HEIGHT - (MARGIN_TOP * cm) - (MARGIN_BOTTOM * cm)
|
||||
|
||||
def __init__(self, school_info=None, export_type='official'):
|
||||
"""
|
||||
Initialize PDF generator.
|
||||
|
||||
Args:
|
||||
school_info (dict): School information {name, address, city, postal_code, school_number, logo_path}
|
||||
export_type (str): 'official' for full DIN 5008 report or 'quick' for compact version
|
||||
"""
|
||||
self.school_info = school_info or {}
|
||||
self.export_type = export_type
|
||||
self.created_timestamp = datetime.datetime.now()
|
||||
self.created_timestamp_iso = self.created_timestamp.isoformat()
|
||||
self.current_page = 1
|
||||
self.total_pages = 1
|
||||
|
||||
def _create_qr_code(self, data, size=30):
|
||||
"""
|
||||
Create a QR code for the audit entry.
|
||||
|
||||
Args:
|
||||
data (str): Data to encode in QR code
|
||||
size (int): Size in pixels
|
||||
|
||||
Returns:
|
||||
Image: PIL Image object
|
||||
"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=4,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
return qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
def _add_header(self, story, responsible_person="IT-Beauftragter"):
|
||||
"""
|
||||
Add DIN 5008 compliant header with school information.
|
||||
|
||||
Args:
|
||||
story (list): Platypus story elements
|
||||
responsible_person (str): Name of responsible person
|
||||
"""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Header spacing for letterhead
|
||||
story.append(Spacer(1, 3 * cm))
|
||||
|
||||
# School information block (left)
|
||||
school_name = self.school_info.get('name', 'Schulname')
|
||||
address = self.school_info.get('address', 'Adresse')
|
||||
postal_code = self.school_info.get('postal_code', 'PLZ')
|
||||
city = self.school_info.get('city', 'Stadt')
|
||||
school_number = self.school_info.get('school_number', 'Schulnummer')
|
||||
|
||||
header_style = ParagraphStyle(
|
||||
'CustomHeader',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
leading=12,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#000000'),
|
||||
)
|
||||
|
||||
logo_path = self.school_info.get('logo_path', '')
|
||||
resolved_logo_path = None
|
||||
if logo_path:
|
||||
candidate_paths = [
|
||||
logo_path,
|
||||
os.path.join(cfg.UPLOAD_FOLDER, logo_path),
|
||||
os.path.join('/opt/Inventarsystem/Web/uploads', logo_path),
|
||||
os.path.join('/var/Inventarsystem/Web/uploads', logo_path),
|
||||
]
|
||||
for candidate_path in candidate_paths:
|
||||
if candidate_path and os.path.exists(candidate_path):
|
||||
resolved_logo_path = candidate_path
|
||||
break
|
||||
|
||||
school_info_text = f"""
|
||||
<b>{school_name}</b><br/>
|
||||
{address}<br/>
|
||||
{postal_code} {city}<br/>
|
||||
<i>Schulnummer: {school_number}</i>
|
||||
"""
|
||||
|
||||
if resolved_logo_path:
|
||||
logo_image = Image(resolved_logo_path)
|
||||
try:
|
||||
logo_image._restrictSize(3.4 * cm, 3.4 * cm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
school_table = Table(
|
||||
[[logo_image, Paragraph(school_info_text, header_style)]],
|
||||
colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm],
|
||||
)
|
||||
school_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 0),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 0),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(school_table)
|
||||
else:
|
||||
story.append(Paragraph(school_info_text, header_style))
|
||||
|
||||
# Information block (right side simulation)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
info_style = ParagraphStyle(
|
||||
'InfoBlock',
|
||||
parent=styles['Normal'],
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#333333'),
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
|
||||
created_date = self.created_timestamp.strftime('%Y-%m-%d')
|
||||
created_time = self.created_timestamp.strftime('%H:%M:%S')
|
||||
|
||||
info_text = f"""
|
||||
<b>Bericht-Informationen:</b><br/>
|
||||
Erstellungsdatum: {created_date}<br/>
|
||||
Uhrzeit: {created_time}<br/>
|
||||
Verantwortliche Person: {responsible_person}<br/>
|
||||
System: Invario v{__version__}
|
||||
"""
|
||||
|
||||
story.append(Paragraph(info_text, info_style))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
def _add_title(self, story, title, subtitle=None):
|
||||
"""Add title and optional subtitle."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=16,
|
||||
leading=20,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#1a1a1a'),
|
||||
spaceAfter=12,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
|
||||
story.append(Paragraph(f"<b>{title}</b>", title_style))
|
||||
|
||||
if subtitle:
|
||||
subtitle_style = ParagraphStyle(
|
||||
'Subtitle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=11,
|
||||
leading=13,
|
||||
fontName='Helvetica-Oblique',
|
||||
textColor=HexColor('#555555'),
|
||||
spaceAfter=12,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
story.append(Paragraph(subtitle, subtitle_style))
|
||||
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_audit_summary(self, story, verify_result, event_counts):
|
||||
"""Add audit chain summary section."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Summary section title
|
||||
summary_title = ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
leading=14,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#1a1a1a'),
|
||||
spaceAfter=8,
|
||||
)
|
||||
|
||||
story.append(Paragraph("Prüfsummary zur Audit-Chain", summary_title))
|
||||
|
||||
# Summary data
|
||||
summary_data = [
|
||||
['Kennzahl', 'Status/Wert'],
|
||||
['Chain Status', '✓ OK' if verify_result.get('ok') else '✗ FEHLER'],
|
||||
['Gesamtzahl Einträge', str(verify_result.get('count', 0))],
|
||||
['Letzter Chain Index', str(verify_result.get('last_chain_index', 0))],
|
||||
['Integritätsabweichungen', str(len(verify_result.get('mismatches', []) or []))],
|
||||
]
|
||||
|
||||
# Add event counts
|
||||
if event_counts:
|
||||
story.append(Spacer(1, 0.1 * cm))
|
||||
story.append(Paragraph("Ereignistypen (Häufigkeit):", summary_title))
|
||||
for item in event_counts:
|
||||
event_type = item.get('event_type', 'unknown')
|
||||
count = item.get('count', 0)
|
||||
summary_data.append([f" {event_type}", str(count)])
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[6*cm, 8*cm])
|
||||
summary_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#e8f4f8')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1a1a1a')),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('FONTSIZE', (0, 1), (-1, -1), 9),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
|
||||
('BACKGROUND', (0, 1), (-1, -1), HexColor('#f9fafb')),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#d1d5db')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f3f4f6')]),
|
||||
]))
|
||||
|
||||
story.append(summary_table)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_events_table(self, story, audit_rows, include_payload=True):
|
||||
"""
|
||||
Add detailed audit events table with professional formatting.
|
||||
|
||||
Args:
|
||||
story (list): Platypus story elements
|
||||
audit_rows (list): Audit log entries
|
||||
include_payload (bool): Include payload details
|
||||
"""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Paragraph("Detaillierte Audit-Ereignisse",
|
||||
ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
fontName='Helvetica-Bold',
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
# Build table data with wrapped cells for better readability on A4 pages
|
||||
cell_style = ParagraphStyle(
|
||||
'EventCell',
|
||||
parent=styles['Normal'],
|
||||
fontName='Helvetica',
|
||||
fontSize=8,
|
||||
leading=9.5,
|
||||
textColor=HexColor('#1f2937'),
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
hash_style = ParagraphStyle(
|
||||
'EventHashCell',
|
||||
parent=cell_style,
|
||||
fontName='Courier',
|
||||
fontSize=7.2,
|
||||
leading=9,
|
||||
wordWrap='CJK',
|
||||
)
|
||||
|
||||
def _safe(value):
|
||||
return str(value or '').replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def _fmt_ts(value):
|
||||
text = _safe(value)
|
||||
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
|
||||
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
|
||||
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
|
||||
return text[:16]
|
||||
|
||||
def _fmt_event(value):
|
||||
text = _safe(value).replace('_', ' ').strip()
|
||||
return text[:60]
|
||||
|
||||
def _chunk_text(value, chunk=4, sep=' '):
|
||||
text = _safe(value)
|
||||
if not text:
|
||||
return ''
|
||||
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
|
||||
|
||||
def _fmt_ip(value):
|
||||
text = _safe(value)
|
||||
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
|
||||
if ':' in text and len(text) > 24:
|
||||
parts = text.split(':')
|
||||
if len(parts) > 4:
|
||||
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
|
||||
return text
|
||||
|
||||
# Build table data
|
||||
if self.export_type == 'quick':
|
||||
# Quick-Check: Minimal columns
|
||||
table_data = [
|
||||
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
|
||||
]
|
||||
|
||||
for row in audit_rows[:20]: # Limit to 20 rows for quick check
|
||||
chain_idx = _safe(row.get('chain_index', ''))
|
||||
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
||||
event_type = _fmt_event(row.get('event_type', ''))
|
||||
actor = _safe(row.get('actor', ''))
|
||||
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
|
||||
if entry_hash:
|
||||
entry_hash += ' ...'
|
||||
|
||||
table_data.append([
|
||||
Paragraph(chain_idx, cell_style),
|
||||
Paragraph(timestamp, cell_style),
|
||||
Paragraph(event_type, cell_style),
|
||||
Paragraph(actor, cell_style),
|
||||
Paragraph(entry_hash, hash_style),
|
||||
])
|
||||
|
||||
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
|
||||
else:
|
||||
# Official Report: Full columns
|
||||
table_data = [
|
||||
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
|
||||
]
|
||||
|
||||
for row in audit_rows:
|
||||
chain_idx = _safe(row.get('chain_index', ''))
|
||||
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
||||
event_type = _fmt_event(row.get('event_type', ''))
|
||||
actor = _safe(row.get('actor', ''))
|
||||
source = _safe(row.get('source', 'System'))
|
||||
ip = _fmt_ip(row.get('ip', ''))
|
||||
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
|
||||
|
||||
table_data.append([
|
||||
Paragraph(chain_idx, cell_style),
|
||||
Paragraph(timestamp, cell_style),
|
||||
Paragraph(event_type, cell_style),
|
||||
Paragraph(actor, cell_style),
|
||||
Paragraph(source, cell_style),
|
||||
Paragraph(ip, cell_style),
|
||||
Paragraph(entry_hash, hash_style),
|
||||
])
|
||||
|
||||
# Give IP and hash columns significantly more room for readability.
|
||||
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
|
||||
|
||||
# Create table
|
||||
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
|
||||
events_table.setStyle(TableStyle([
|
||||
# Header styling
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
|
||||
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 8.5),
|
||||
('TOPPADDING', (0, 0), (-1, 0), 6),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
|
||||
|
||||
# Body styling
|
||||
('FONTSIZE', (0, 1), (-1, -1), 8),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
|
||||
('TOPPADDING', (0, 1), (-1, -1), 5),
|
||||
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
|
||||
('LEFTPADDING', (0, 1), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 1), (-1, -1), 4),
|
||||
('ALIGN', (0, 1), (0, -1), 'CENTER'),
|
||||
('ALIGN', (1, 1), (1, -1), 'CENTER'),
|
||||
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
|
||||
]))
|
||||
|
||||
story.append(events_table)
|
||||
story.append(Paragraph(
|
||||
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
|
||||
ParagraphStyle(
|
||||
'TableHint',
|
||||
parent=styles['Normal'],
|
||||
fontSize=7.5,
|
||||
leading=9,
|
||||
textColor=HexColor('#6b7280'),
|
||||
alignment=TA_LEFT,
|
||||
spaceBefore=4,
|
||||
)
|
||||
))
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
|
||||
def _add_mismatches(self, story, mismatches):
|
||||
"""Add integrity mismatches section if any."""
|
||||
if not mismatches:
|
||||
return
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
story.append(Paragraph("Integritätsabweichungen",
|
||||
ParagraphStyle(
|
||||
'WarningTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#d32f2f'),
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
mismatch_data = [['Index', 'Fehlertyp', 'Erwartet', 'Gefunden']]
|
||||
|
||||
for m in mismatches:
|
||||
mismatch_data.append([
|
||||
str(m.get('chain_index', '')),
|
||||
str(m.get('error', '')),
|
||||
str(m.get('expected', ''))[:30],
|
||||
str(m.get('found', ''))[:30],
|
||||
])
|
||||
|
||||
mismatch_table = Table(mismatch_data, colWidths=[1.5*cm, 3*cm, 5*cm, 5*cm])
|
||||
mismatch_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#ffebee')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#c62828')),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#f44336')),
|
||||
('BACKGROUND', (0, 1), (-1, -1), HexColor('#fdeaea')),
|
||||
]))
|
||||
|
||||
story.append(mismatch_table)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_signature_block(self, story):
|
||||
"""Add signature block for school administration approval."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
story.append(Paragraph("Prüfvermerk und Bestätigung",
|
||||
ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=11,
|
||||
fontName='Helvetica-Bold',
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
sig_text = """
|
||||
Hiermit wird die Richtigkeit und Vollständigkeit der im Audit-Report dokumentierten
|
||||
Ereignisse und deren Integrität bestätigt. Dieses Dokument wurde revisionssicher erstellt
|
||||
und archiviert.
|
||||
"""
|
||||
|
||||
story.append(Paragraph(sig_text,
|
||||
ParagraphStyle(
|
||||
'SigText',
|
||||
parent=styles['Normal'],
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
alignment=TA_JUSTIFY,
|
||||
spaceAfter=12,
|
||||
)))
|
||||
|
||||
# Signature lines
|
||||
sig_data = [
|
||||
['Schulleitung', '', 'IT-Beauftragter'],
|
||||
['', '', ''],
|
||||
['_' * 35, '', '_' * 35],
|
||||
['Unterschrift / Datum', '', 'Unterschrift / Datum'],
|
||||
]
|
||||
|
||||
sig_table = Table(sig_data, colWidths=[5*cm, 2*cm, 5*cm])
|
||||
sig_table.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 9),
|
||||
('FONTSIZE', (0, 3), (-1, 3), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 2),
|
||||
]))
|
||||
|
||||
story.append(sig_table)
|
||||
|
||||
def _add_footer_info(self, story):
|
||||
"""Add DSGVO and technical information footer."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
footer_style = ParagraphStyle(
|
||||
'Footer',
|
||||
parent=styles['Normal'],
|
||||
fontSize=8,
|
||||
leading=10,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#666666'),
|
||||
alignment=TA_CENTER,
|
||||
spaceAfter=4,
|
||||
)
|
||||
|
||||
dsgvo_text = "Dieses Dokument wurde datenschutzkonform erstellt. Speicherung auf zertifizierten Servern in Deutschland."
|
||||
tech_text = f"Generiert am {self.created_timestamp.strftime('%d.%m.%Y um %H:%M:%S')} durch System Invario (PDF/A-Format, revisionssicher)"
|
||||
|
||||
story.append(Paragraph(dsgvo_text, footer_style))
|
||||
story.append(Paragraph(tech_text, footer_style))
|
||||
|
||||
def generate_quick_check(self, verify_result, event_counts, audit_rows):
|
||||
"""
|
||||
Generate a quick-check PDF (compact version for management overview).
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
output = io.BytesIO()
|
||||
|
||||
story = []
|
||||
|
||||
# Header
|
||||
self._add_header(story, "Verwaltung")
|
||||
|
||||
# Title
|
||||
self._add_title(story,
|
||||
"Audit-Report: Schnell-Check",
|
||||
f"Überblick zum {self.created_timestamp.strftime('%d.%m.%Y')}")
|
||||
|
||||
# Summary
|
||||
self._add_audit_summary(story, verify_result, event_counts)
|
||||
|
||||
# Events table (limited)
|
||||
self._add_events_table(story, audit_rows, include_payload=False)
|
||||
|
||||
# Mismatches if any
|
||||
mismatches = verify_result.get('mismatches', []) or []
|
||||
if mismatches:
|
||||
self._add_mismatches(story, mismatches)
|
||||
|
||||
# Footer
|
||||
self._add_footer_info(story)
|
||||
|
||||
# Build PDF
|
||||
doc = SimpleDocTemplate(
|
||||
output,
|
||||
pagesize=A4,
|
||||
topMargin=self.MARGIN_TOP * cm,
|
||||
bottomMargin=self.MARGIN_BOTTOM * cm,
|
||||
leftMargin=self.MARGIN_LEFT * cm,
|
||||
rightMargin=self.MARGIN_RIGHT * cm,
|
||||
title="Audit Quick-Check Report",
|
||||
author="Invario System",
|
||||
subject="Audit Report - Quick Check",
|
||||
creator="Invario",
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
def generate_official_report(self, verify_result, event_counts, audit_rows):
|
||||
"""
|
||||
Generate a full official audit report (DIN 5008 compliant for authorities).
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
output = io.BytesIO()
|
||||
|
||||
story = []
|
||||
|
||||
# Header
|
||||
self._add_header(story, self.school_info.get('it_admin', 'IT-Beauftragter'))
|
||||
|
||||
# Title
|
||||
reporting_date = self.created_timestamp.strftime('%d.%m.%Y')
|
||||
self._add_title(story,
|
||||
"Audit-Protokoll",
|
||||
f"Revisonssicheres Audit-Log - Berichtsstand: {reporting_date}")
|
||||
|
||||
# Summary
|
||||
self._add_audit_summary(story, verify_result, event_counts)
|
||||
|
||||
# Mismatches section (prominently)
|
||||
mismatches = verify_result.get('mismatches', []) or []
|
||||
if mismatches:
|
||||
self._add_mismatches(story, mismatches)
|
||||
|
||||
# Full events table
|
||||
self._add_events_table(story, audit_rows, include_payload=True)
|
||||
|
||||
# Page break for signature section
|
||||
story.append(PageBreak())
|
||||
|
||||
# Signature block
|
||||
self._add_signature_block(story)
|
||||
|
||||
# Footer
|
||||
self._add_footer_info(story)
|
||||
|
||||
# Build PDF
|
||||
doc = SimpleDocTemplate(
|
||||
output,
|
||||
pagesize=A4,
|
||||
topMargin=self.MARGIN_TOP * cm,
|
||||
bottomMargin=self.MARGIN_BOTTOM * cm,
|
||||
leftMargin=self.MARGIN_LEFT * cm,
|
||||
rightMargin=self.MARGIN_RIGHT * cm,
|
||||
title="Audit Official Report",
|
||||
author="Invario System",
|
||||
subject="Offizielle Audit-Bericht (DIN 5008)",
|
||||
creator="Invario",
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='official', school_info=None):
|
||||
"""
|
||||
Convenience function to generate audit PDFs.
|
||||
|
||||
Args:
|
||||
verify_result (dict): Verification result from audit chain
|
||||
event_counts (list): Event count statistics
|
||||
audit_rows (list): Audit log entries
|
||||
export_type (str): 'official' or 'quick'
|
||||
school_info (dict): School information
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
pdf_gen = DIN5008AuditPDF(school_info=school_info, export_type=export_type)
|
||||
|
||||
if export_type == 'quick':
|
||||
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
|
||||
else:
|
||||
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
|
||||
|
||||
def _build_invoice_pdf(invoice_data):
|
||||
"""Render a PDF invoice for a damaged borrowed item."""
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.colors import HexColor, black, white
|
||||
from reportlab.lib.utils import simpleSplit
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
c = canvas.Canvas(pdf_buffer, pagesize=A4)
|
||||
page_width, page_height = A4
|
||||
|
||||
margin_x = 20 * mm
|
||||
margin_top = 20 * mm
|
||||
usable_width = page_width - (2 * margin_x)
|
||||
current_y = page_height - margin_top
|
||||
|
||||
dark_color = HexColor('#0F172A')
|
||||
accent_color = HexColor('#B91C1C')
|
||||
light_color = HexColor('#F8FAFC')
|
||||
border_color = HexColor('#CBD5E1')
|
||||
text_color = HexColor('#1E293B')
|
||||
muted_color = HexColor('#64748B')
|
||||
|
||||
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
|
||||
if not text:
|
||||
return y_pos
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColor(color)
|
||||
for line in simpleSplit(str(text), font_name, font_size, width):
|
||||
c.drawString(x_pos, y_pos, line)
|
||||
y_pos -= leading
|
||||
return y_pos
|
||||
|
||||
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
|
||||
c.setFont('Helvetica-Bold', 10)
|
||||
c.setFillColor(muted_color)
|
||||
c.drawString(x_pos, y_pos, label)
|
||||
c.setFont('Helvetica', 10)
|
||||
c.setFillColor(text_color)
|
||||
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
|
||||
return y_pos - 7 * mm
|
||||
|
||||
c.setFillColor(light_color)
|
||||
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
|
||||
c.setFillColor(white)
|
||||
c.setFont('Helvetica-Bold', 20)
|
||||
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
|
||||
c.setFont('Helvetica', 10)
|
||||
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
|
||||
|
||||
current_y = page_height - 40 * mm
|
||||
c.setStrokeColor(border_color)
|
||||
c.setLineWidth(1)
|
||||
c.line(margin_x, current_y, page_width - margin_x, current_y)
|
||||
current_y -= 12 * mm
|
||||
|
||||
invoice_number = invoice_data.get('invoice_number', '-')
|
||||
created_at = invoice_data.get('created_at_display', '-')
|
||||
borrower = invoice_data.get('borrower', '-')
|
||||
item_name = invoice_data.get('item_name', '-')
|
||||
item_code = invoice_data.get('item_code', '-')
|
||||
item_id = invoice_data.get('item_id', '-')
|
||||
damage_reason = invoice_data.get('damage_reason', '-')
|
||||
amount_text = invoice_data.get('amount_text', '-')
|
||||
|
||||
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
|
||||
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
|
||||
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
|
||||
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
|
||||
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
|
||||
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
|
||||
current_y = current_y - 3 * mm
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.setFont('Helvetica-Bold', 12)
|
||||
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
|
||||
current_y -= 6 * mm
|
||||
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
|
||||
current_y -= 4 * mm
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.setFont('Helvetica-Bold', 12)
|
||||
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
|
||||
current_y -= 8 * mm
|
||||
|
||||
c.setFillColor(accent_color)
|
||||
c.setStrokeColor(accent_color)
|
||||
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
|
||||
c.setFillColor(white)
|
||||
c.setFont('Helvetica-Bold', 16)
|
||||
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
|
||||
current_y -= 20 * mm
|
||||
|
||||
current_y = draw_wrapped_lines(
|
||||
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
|
||||
margin_x,
|
||||
current_y,
|
||||
usable_width,
|
||||
font_size=10,
|
||||
leading=13,
|
||||
color=muted_color,
|
||||
)
|
||||
|
||||
footer_y = 18 * mm
|
||||
c.setStrokeColor(border_color)
|
||||
c.setLineWidth(0.8)
|
||||
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
|
||||
c.setFillColor(muted_color)
|
||||
c.setFont('Helvetica', 9)
|
||||
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
|
||||
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
|
||||
|
||||
c.save()
|
||||
pdf_buffer.seek(0)
|
||||
return pdf_buffer
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
class ModuleRegistry:
|
||||
def __init__(self):
|
||||
self._modules: Dict[str, Any] = {}
|
||||
self._path_matchers = {}
|
||||
|
||||
def register(self, name: str, settings_bool, path_matcher=lambda path: False):
|
||||
self._modules[name] = settings_bool
|
||||
self._path_matchers[name] = path_matcher
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
if name not in self._modules:
|
||||
return False
|
||||
return bool(self._modules[name])
|
||||
|
||||
def get_all_status(self) -> Dict[str, bool]:
|
||||
return {name: bool(sys_bool) for name, sys_bool in self._modules.items()}
|
||||
|
||||
def get_module_for_path(self, path: str) -> str:
|
||||
for name, matcher in self._path_matchers.items():
|
||||
if self.is_enabled(name) and matcher(path):
|
||||
return name
|
||||
return None
|
||||
|
||||
registry = ModuleRegistry()
|
||||
@@ -0,0 +1 @@
|
||||
print("hello")
|
||||
Reference in New Issue
Block a user