Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4d5cd6436 | |||
| 9fd9d63e31 | |||
| 08cce1b884 | |||
| adde73dffb | |||
| da17667d90 | |||
| c69231fa77 | |||
| ac43178b29 | |||
| a2d58208e6 | |||
| f8171a5f18 | |||
| 6f898ffea4 | |||
| b29cc38a3d | |||
| 17745c64e0 | |||
| a0279f82e1 | |||
| 8d1e3865d3 |
+1
-1
@@ -1 +1 @@
|
||||
v0.7.71
|
||||
v0.7.77
|
||||
|
||||
@@ -4177,6 +4177,7 @@ def logout():
|
||||
session.pop('is_admin', None)
|
||||
session.pop('favorites', None)
|
||||
session.pop('favorites_owner', None)
|
||||
session.pop('tenant_id', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+121
-34
@@ -7,11 +7,12 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
|
||||
Each tenant can support up to 20+ users with isolated data and resource pools.
|
||||
"""
|
||||
|
||||
from flask import request, g, has_request_context
|
||||
from flask import request, g, session, has_request_context
|
||||
from functools import wraps
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ipaddress
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
|
||||
@@ -65,6 +66,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,6 +110,33 @@ 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."""
|
||||
if tenant_id is None:
|
||||
@@ -207,45 +256,83 @@ 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)
|
||||
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)
|
||||
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)
|
||||
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 ''
|
||||
if session_tenant:
|
||||
self.tenant_id = session_tenant
|
||||
self.config = get_tenant_config(session_tenant)
|
||||
logger.info(
|
||||
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(session_tenant)
|
||||
|
||||
# Fallback to default tenant if no tenant identifier found.
|
||||
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
|
||||
|
||||
@@ -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
|
||||
|
||||
+80
-16
@@ -72,6 +72,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 +105,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)
|
||||
@@ -214,13 +242,15 @@ EOF
|
||||
}
|
||||
|
||||
restart_app_container() {
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local workdir="$SCRIPT_DIR"
|
||||
local env_file="$SCRIPT_DIR/.docker-build.env"
|
||||
local compose_args=()
|
||||
|
||||
ensure_runtime_config_json
|
||||
|
||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
||||
if [ -n "$HOST_WORKDIR" ]; then
|
||||
if [ -n "${HOST_WORKDIR:-}" ]; then
|
||||
workdir="$HOST_WORKDIR"
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
|
||||
@@ -230,9 +260,9 @@ restart_app_container() {
|
||||
fi
|
||||
else
|
||||
# Normal case: called directly from host
|
||||
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" )
|
||||
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
|
||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||
fi
|
||||
if [ -f "$env_file" ]; then
|
||||
compose_args+=( --env-file "$env_file" )
|
||||
@@ -240,7 +270,7 @@ restart_app_container() {
|
||||
fi
|
||||
|
||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
||||
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
|
||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||
fi
|
||||
|
||||
@@ -261,9 +291,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)
|
||||
@@ -578,11 +619,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:
|
||||
@@ -594,6 +650,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
|
||||
|
||||
Reference in New Issue
Block a user