Compare commits

..

12 Commits

7 changed files with 746 additions and 80 deletions
+1 -1
View File
@@ -1 +1 @@
v0.7.73
v0.7.78
+62 -10
View File
@@ -1,11 +1,3 @@
'''
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
'''
"""
Inventarsystem - Flask Web Application
@@ -90,7 +82,7 @@ from Web.modules.inventarsystem.data_protection import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from tenant import get_tenant_context
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -362,6 +354,54 @@ def _enforce_active_session_user():
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
@app.before_request
def _enforce_trial_tenant_expiry():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in {'login', 'logout', 'impressum', 'license'}:
return None
try:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx else session.get('tenant_id')
if not tenant_id:
return None
trial_status = get_tenant_trial_status(tenant_id)
if not trial_status.get('enabled'):
return None
session['trial_status'] = {
'enabled': True,
'expired': bool(trial_status.get('expired')),
'days_left': trial_status.get('days_left'),
'expires_at': trial_status.get('expires_at').isoformat() if trial_status.get('expires_at') else None,
}
if not trial_status.get('expired'):
return None
if request.path.startswith('/api/') or request.is_json:
return jsonify({
'ok': False,
'message': 'Diese Testinstanz ist abgelaufen und wurde deaktiviert.',
'expired': True,
}), 410
session.pop('username', None)
session.pop('admin', None)
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
flash('Diese Testinstanz ist abgelaufen und wurde deaktiviert.', 'error')
return redirect(url_for('login'))
except Exception as exc:
app.logger.warning(f'Trial expiry check failed: {exc}')
return None
@app.before_request
def _enforce_module_access():
endpoint = request.endpoint or ''
@@ -380,7 +420,7 @@ def _enforce_module_access():
return jsonify({'ok': False, 'message': msg}), 403
flash(msg, 'info')
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
@@ -1116,6 +1156,7 @@ def inject_version():
'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(),
'trial_status': session.get('trial_status', {}),
}
@@ -1132,6 +1173,16 @@ def create_daily_backup():
except Exception as e:
app.logger.error(f"Daily backup creation failed: {e}")
def cleanup_expired_trial_tenants():
"""Remove expired trial tenants from config and MongoDB."""
try:
purged_tenants = purge_expired_trial_tenants()
if purged_tenants:
app.logger.warning(f"Purged expired trial tenants: {', '.join(purged_tenants)}")
except Exception as exc:
app.logger.error(f"Trial tenant cleanup failed: {exc}")
def update_appointment_statuses():
"""
Aktualisiert automatisch die Status aller Terminplaner-Einträge.
@@ -1327,6 +1378,7 @@ def _initialize_scheduler():
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=cleanup_expired_trial_tenants, trigger="interval", hours=1)
scheduler.start()
_scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
+22 -2
View File
@@ -100,9 +100,29 @@ DEFAULTS = {
}
# Load configuration file
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json')
def _resolve_config_path():
"""Resolve runtime config path with robust fallbacks for Docker deployments."""
env_path = os.getenv('INVENTAR_CONFIG_PATH', '').strip()
if env_path:
return env_path
candidates = [
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))), 'config.json'),
os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'config.json'),
os.path.join(os.path.dirname(BASE_DIR), 'config.json'),
os.path.join(BASE_DIR, '..', 'config.json'),
]
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return os.path.abspath(candidates[0])
CONFIG_PATH = _resolve_config_path()
try:
with open(CONFIG_PATH, 'r') as f:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
_conf = json.load(f)
except Exception:
_conf = {}
+53 -17
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
'''
import hashlib
import copy
import importlib
import logging
import re
import secrets
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
return client[cfg.MONGODB_DB]
def _has_tenant_configs():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
if isinstance(tenant_registry, dict) and tenant_registry:
return True
except Exception:
continue
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
def _resolve_request_tenant_db():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
if not callable(get_tenant_context):
continue
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
if ctx and ctx.db_name and not _has_tenant_configs():
return ctx.db_name, None
except Exception as exc:
logger.debug("Tenant context import %s failed: %s", module_name, exc)
return None, None
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)
@@ -424,22 +454,26 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = None
db_name, tenant_id = _resolve_request_tenant_db()
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}")
except Exception:
ctx = None
if not db_name:
if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None
db_name = cfg.MONGODB_DB
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,
tenant_id,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
@@ -644,15 +678,17 @@ def get_user(username):
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
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
user = find_in_db(tenant_db)
if user:
return user
if _has_tenant_configs() and tenant_db is None:
logger.warning(
"Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
)
return None
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
+333 -40
View File
@@ -9,18 +9,59 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, session, has_request_context
from functools import wraps
import datetime
import json
import logging
import os
import re
import ipaddress
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
logger = logging.getLogger(__name__)
_TENANT_REGISTRY_MTIME = None
def _load_tenant_registry_from_config():
registry = {}
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if isinstance(tenants, dict):
registry.update(tenants)
except Exception as exc:
logger.warning("Failed to load tenant registry from config: %s", exc)
elif isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
registry.update(cfg.TENANT_CONFIGS)
return registry
def _refresh_tenant_registry():
global TENANT_REGISTRY, _TENANT_REGISTRY_MTIME
config_path = getattr(cfg, 'CONFIG_PATH', None)
current_mtime = None
if config_path and os.path.isfile(config_path):
try:
current_mtime = os.path.getmtime(config_path)
except OSError:
current_mtime = None
if current_mtime == _TENANT_REGISTRY_MTIME:
return TENANT_REGISTRY
TENANT_REGISTRY.clear()
TENANT_REGISTRY.update(_load_tenant_registry_from_config())
_TENANT_REGISTRY_MTIME = current_mtime
return TENANT_REGISTRY
# 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)
TENANT_REGISTRY = _load_tenant_registry_from_config()
def _get_nested_value(source, path, default=None):
@@ -65,6 +106,27 @@ def _parse_port_from_host(host):
return host, None
def _first_host_token(value):
raw = str(value or '').strip()
if not raw:
return ''
return raw.split(',', 1)[0].strip().lower()
def _request_host_candidates():
candidates = []
for header_name in ('X-Forwarded-Host', 'X-Original-Host', 'Host'):
token = _first_host_token(request.headers.get(header_name, ''))
if token and token not in candidates:
candidates.append(token)
direct_host = _first_host_token(getattr(request, 'host', ''))
if direct_host and direct_host not in candidates:
candidates.append(direct_host)
return candidates
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
@@ -88,8 +150,37 @@ def _tenant_id_for_port(port):
return None
def _find_registered_tenant_id(candidate):
candidate = str(candidate or '').strip()
if not candidate:
return None
if candidate in TENANT_REGISTRY:
return candidate
lowered = candidate.lower()
for tenant_id in TENANT_REGISTRY:
if str(tenant_id).lower() == lowered:
return tenant_id
return None
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
return False
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
_refresh_tenant_registry()
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
@@ -183,6 +274,183 @@ def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
return bool(default)
def _parse_datetime_value(value):
if value is None or value == '':
return None
if isinstance(value, datetime.datetime):
parsed = value
else:
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def get_tenant_trial_config(tenant_id=None):
"""Return the optional trial/demo lifecycle settings for a tenant."""
config = get_tenant_config(tenant_id)
trial_config = _get_nested_value(config, ['trial'], {})
if not isinstance(trial_config, dict):
trial_config = {}
demo_config = _get_nested_value(config, ['demo'], {})
if isinstance(demo_config, dict):
merged = dict(demo_config)
merged.update(trial_config)
trial_config = merged
return trial_config
def get_tenant_trial_status(tenant_id=None, now=None):
"""Compute the current trial status for a tenant.
The config may define either an absolute "expires_at" timestamp or a
relative lifetime via "started_at"/"created_at" plus one of
"expires_after_days", "ttl_days", or "days".
"""
trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now()
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled:
return {
'enabled': False,
'expired': False,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': None,
'expires_at': None,
'days_left': None,
}
started_at = _parse_datetime_value(
trial_config.get('started_at')
or trial_config.get('created_at')
or trial_config.get('activated_at')
)
expires_at = _parse_datetime_value(trial_config.get('expires_at'))
if expires_at is None:
duration_days = trial_config.get('expires_after_days')
if duration_days is None:
duration_days = trial_config.get('ttl_days')
if duration_days is None:
duration_days = trial_config.get('days')
try:
duration_days = int(duration_days) if duration_days is not None else None
except (TypeError, ValueError):
duration_days = None
if duration_days is not None:
base_time = started_at or now
expires_at = base_time + datetime.timedelta(days=max(0, duration_days))
expired = bool(expires_at and now >= expires_at)
days_left = None
if expires_at:
remaining = expires_at - now
days_left = max(0, int(remaining.total_seconds() // 86400))
return {
'enabled': True,
'expired': expired,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': started_at,
'expires_at': expires_at,
'days_left': days_left,
}
def _get_tenant_db_name_from_config(tenant_id):
config = get_tenant_config(tenant_id)
explicit_db = None
if isinstance(config, dict):
explicit_db = config.get('db') or config.get('db_name')
if explicit_db:
return _normalize_db_name(explicit_db)
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in str(tenant_id).lower())
return f'inventar_{sanitized}' if sanitized else cfg.MONGODB_DB
def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
"""Delete a tenant's runtime data and optionally remove its config entry."""
tenant_id = str(tenant_id or '').strip()
if not tenant_id:
return False
db_name = _get_tenant_db_name_from_config(tenant_id)
if drop_database:
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
client.drop_database(db_name)
finally:
client.close()
except Exception as exc:
logger.warning("Failed to drop tenant database %s for %s: %s", db_name, tenant_id, exc)
if remove_from_config:
try:
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if not isinstance(tenants, dict):
tenants = {}
aliases = set(_tenant_db_aliases(tenant_id))
aliases.add(tenant_id)
lowered = tenant_id.lower()
if lowered.startswith('schule'):
aliases.add('school' + lowered[len('schule'):])
elif lowered.startswith('school'):
aliases.add('schule' + lowered[len('school'):])
removed_any = False
for alias in aliases:
if alias in tenants:
tenants.pop(alias, None)
removed_any = True
if removed_any:
config['tenants'] = tenants
with open(config_path, 'w', encoding='utf-8') as handle:
json.dump(config, handle, indent=4, ensure_ascii=False)
for alias in [tenant_id, *_tenant_db_aliases(tenant_id)]:
TENANT_REGISTRY.pop(alias, None)
except Exception as exc:
logger.warning("Failed to remove tenant config for %s: %s", tenant_id, exc)
return True
def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now()
purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()):
status = get_tenant_trial_status(tenant_id, now=now)
if status.get('enabled') and status.get('expired') and status.get('auto_delete'):
if delete_tenant(tenant_id):
purged_tenants.append(tenant_id)
return purged_tenants
class TenantContext:
"""
Manages current tenant context for request lifecycle.
@@ -207,48 +475,73 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
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)
session['tenant_id'] = tenant_from_header
return self._get_db_name(tenant_from_header)
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port:
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 2: Port/host based tenant mapping
host_candidates = _request_host_candidates()
primary_host = host_candidates[0] if host_candidates else _first_host_token(getattr(request, 'host', ''))
logger.info(
"Tenant resolution start: request.host=%s host_candidates=%s request.headers=%s",
getattr(request, 'host', ''),
host_candidates,
dict(request.headers),
)
for host in host_candidates:
hostname, port = _parse_port_from_host(host)
if port:
self.port = port
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction
parts = host.split('.')
for host in host_candidates:
host_without_port, _ = _parse_port_from_host(host)
host_without_port = (host_without_port or '').strip().lower()
if not host_without_port:
continue
# Extract subdomain from host
# Examples: schule1.example.com → schule1
# app.example.com → app (skip wildcard/app)
if len(parts) >= 3:
potential_subdomain = parts[0]
# Filter out common non-tenant subdomains
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)
session['tenant_id'] = potential_subdomain
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
return self._get_db_name(direct_host_match)
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
@@ -256,7 +549,7 @@ class TenantContext:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={host} tenant={session_tenant} config={self.config}"
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
+1
View File
@@ -95,6 +95,7 @@ services:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: inventar_default
INVENTAR_CONFIG_PATH: /app/config.json
# Redis Configuration (for sessions and caching)
INVENTAR_REDIS_HOST: redis
+274 -10
View File
@@ -56,6 +56,7 @@ show_help() {
echo ""
echo "Commands:"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
@@ -65,6 +66,7 @@ show_help() {
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh trial school_demo 10002 7"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all"
@@ -72,6 +74,24 @@ show_help() {
exit 1
}
tenant_aliases() {
local tenant_id="$1"
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
if [[ "$normalized" == schule* ]]; then
alias="school${normalized#schule}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
elif [[ "$normalized" == school* ]]; then
alias="schule${normalized#school}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
fi
}
register_tenant_port() {
local tenant_id="$1"
local port="$2"
@@ -87,15 +107,25 @@ with open(path, 'r', encoding='utf-8') as f:
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str)
tenants[tenant_id] = existing
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -109,6 +139,176 @@ PY
fi
}
write_trial_tenant_config() {
local tenant_id="$1"
local port="$2"
local trial_days="$3"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days"
import json, sys, os, datetime
path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
try:
trial_days = max(1, int(trial_days_str))
except ValueError:
print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr)
sys.exit(3)
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
trial_config = {
'enabled': True,
'auto_delete': True,
'days': trial_days,
'ttl_days': trial_days,
'expires_after_days': trial_days,
'started_at': now,
'created_at': now,
}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
if port_str:
existing['port'] = int(port_str)
existing['modules'] = {
'inventory': {'enabled': True},
'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
}
existing['trial'] = trial_config
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled")
PY
then
echo "Trial tenant $tenant_id configured in config.json"
else
echo "Failed to configure trial tenant $tenant_id"
exit 1
fi
}
initialize_tenant_database() {
local tenant_id="$1"
local mode="$2"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
return 0
fi
docker exec "$APP_CONTAINER" python3 -c '
import sys, re, datetime, hashlib
sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1].lower()
mode = sys.argv[2]
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
action_permissions = {
"can_borrow": True,
"can_insert": True,
"can_edit": True,
"can_delete": True,
"can_manage_users": True,
"can_manage_settings": True,
"can_view_logs": True,
}
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": 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,
}
if db.users.count_documents({"Username": "admin"}) == 0:
db.users.insert_one({
"Username": "admin",
"Password": hashed_pw,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
"last_name": "User",
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
"PagePermissions": page_permissions,
})
if mode == "trial":
db.settings.update_one(
{"setting_type": "tenant_trial"},
{"$set": {
"setting_type": "tenant_trial",
"enabled": True,
"auto_delete": True,
"days": 7,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}},
upsert=True,
)
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
' "$tenant_id" "$mode"
}
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
@@ -263,9 +463,20 @@ if not os.path.isfile(path):
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
if not isinstance(tenants, dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
removed = None
for alias in list(aliases):
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
removed = alias_cfg if removed is None else removed
tenants.pop(alias, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -432,6 +643,36 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Data will be initialized upon first access by the tenant."
fi
;;
trial)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="${3:-}"
DAYS_ARG="${4:-7}"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Initializing trial database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "trial"
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
;;
remove)
if [ -z "$TENANT_ID" ]; then
@@ -580,11 +821,26 @@ with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
if 'port' not in tenant_cfg:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
tenant_cfg = None
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
tenant_cfg = alias_cfg
break
if tenant_cfg is None:
tenant_cfg = {}
modules = tenant_cfg.setdefault('modules', {})
port = tenant_cfg.get('port')
if port is None:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
for arg in module_args:
if '=' not in arg:
@@ -596,6 +852,14 @@ for arg in module_args:
module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
for alias in aliases:
alias_cfg = tenants.get(alias)
if not isinstance(alias_cfg, dict):
alias_cfg = {}
alias_cfg.update(tenant_cfg)
alias_cfg['modules'] = modules
tenants[alias] = alias_cfg
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY