Compare commits

...

9 Commits

10 changed files with 205 additions and 34 deletions
+26
View File
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
---
## Schul-Konfiguration pro Tenant
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
```json
{
"tenants": {
"schule1": {
"modules": {
"library": { "enabled": true },
"student_cards": { "enabled": false }
}
},
"schule2": {
"modules": {
"library": { "enabled": false }
}
}
}
}
```
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
---
## Support & Debugging
**Fragen?**
+22
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
return None
@app.before_request
def _enforce_active_session_user():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
username = session.get('username')
if not username:
return None
user = us.get_user(username)
if user:
return None
session.clear()
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
+30 -2
View File
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
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})"
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']))
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']))
+36
View File
@@ -10,11 +10,43 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, has_request_context
from functools import wraps
import logging
import settings as cfg
logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {}
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
def _get_nested_value(source, path, default=None):
current = source
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
return TENANT_REGISTRY.get('default', {}) or {}
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
return bool(enabled)
class TenantContext:
@@ -27,6 +59,7 @@ class TenantContext:
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.config = {}
def resolve_tenant(self):
"""
@@ -40,6 +73,7 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction
@@ -56,10 +90,12 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(potential_subdomain)
# Fallback to default tenant if no subdomain detected
self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default')
def _get_db_name(self, tenant_id):
+2
View File
@@ -50,6 +50,8 @@
}
},
"tenants": {},
"allowed_extensions": [
"png", "jpg", "jpeg", "gif",
"hevc", "heif",
+18 -1
View File
@@ -9,6 +9,22 @@
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
profiles:
- cloudflare
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
volumes:
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
depends_on:
nginx:
condition: service_started
networks:
- inventar-net
# Management Container for multi-tenant scripts
tenant-manager:
image: docker:cli
@@ -92,6 +108,8 @@ services:
args:
PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
restart: unless-stopped
security_opt:
- no-new-privileges:true
@@ -106,7 +124,6 @@ services:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached
+16 -1
View File
@@ -1,4 +1,18 @@
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
profiles:
- cloudflare
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
volumes:
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
depends_on:
nginx:
condition: service_started
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
@@ -42,6 +56,8 @@ services:
build:
context: .
dockerfile: Dockerfile
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
container_name: inventarsystem-app
restart: unless-stopped
security_opt:
@@ -66,7 +82,6 @@ services:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
+17
View File
@@ -20,6 +20,7 @@ HTTPS_PORT_VALUE="8443"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
usage() {
cat <<EOF
@@ -439,6 +440,16 @@ resolve_app_image() {
fi
}
configure_cloudflared_profile() {
if [ -f /etc/cloudflared/credentials.json ]; then
COMPOSE_PROFILES_VALUE="cloudflare"
echo "Cloudflared tunnel: enabled (homeserver)"
else
COMPOSE_PROFILES_VALUE=""
echo "Cloudflared tunnel: disabled (missing /etc/cloudflared/credentials.json)"
fi
}
port_in_use() {
local port="$1"
@@ -564,6 +575,8 @@ write_runtime_compose_override() {
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF
@@ -625,6 +638,7 @@ ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
configure_cloudflared_profile
configure_host_ports
ensure_app_image_loaded
write_env_file
@@ -636,6 +650,9 @@ if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_up_args+=(--env-file "$ENV_FILE")
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
fi
docker compose "${compose_up_args[@]}" up -d --remove-orphans
verify_stack_health
+30 -30
View File
@@ -119,6 +119,36 @@ server {
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
log_message "Recreated missing nginx config at $config_path"
fi
}
usage() {
cat <<EOF
Usage: $0 [options]
@@ -154,36 +184,6 @@ parse_args() {
done
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
log_message "Recreated missing nginx config at $config_path"
fi
}
archive_logs() {
log_message "Checking for monthly log archival..."
if [ -x "$PROJECT_DIR/archive-logs.sh" ]; then