mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
refactor (backend): move config files out of the source directories (#9769)
* moving config files out of the source directories Fixes #9756 * add folder for config * fix lookup paths * reorder ignores * reduce diff * better error message * fix paths * Update tests.py * save global warning to db * fix import * more import fixes / docs * fix default * fix default * ensure secret_key/get_config_file is tested fully * try fixing path on docker * try to make it work on GitHub CI * refactor testfolder path into config var * fix test path * fix test * do not test on docker * more tests * add testing for global warning dict * fix error handling
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@ -46,19 +46,17 @@ inventree_media
|
|||||||
inventree_static
|
inventree_static
|
||||||
static_i18n
|
static_i18n
|
||||||
|
|
||||||
# Local config file
|
# Local config files
|
||||||
config.yaml
|
config.yaml
|
||||||
plugins.txt
|
plugins.txt
|
||||||
|
secret_key.txt
|
||||||
|
oidc.pem
|
||||||
|
|
||||||
# Default data file
|
# Default data file
|
||||||
data.json
|
data.json
|
||||||
*.json.tmp
|
*.json.tmp
|
||||||
*.tmp.json
|
*.tmp.json
|
||||||
|
|
||||||
# Key file
|
|
||||||
secret_key.txt
|
|
||||||
oidc.pem
|
|
||||||
|
|
||||||
# IDE / development files
|
# IDE / development files
|
||||||
.idea/
|
.idea/
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
1
config/.gitignore
vendored
Normal file
1
config/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*
|
1
config/README
Normal file
1
config/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
This folder is recommended for configuration values and excluded from change tracking in git
|
@ -111,6 +111,12 @@ Steps very between deployment methods.
|
|||||||
The command that was used to run invoke is not the one that is recommended. This might be caused by a wrong PATH variable or by thinking you are using a different deployment method.
|
The command that was used to run invoke is not the one that is recommended. This might be caused by a wrong PATH variable or by thinking you are using a different deployment method.
|
||||||
The warning text will show the recommended command for intended use.
|
The warning text will show the recommended command for intended use.
|
||||||
|
|
||||||
|
#### INVE-W10
|
||||||
|
**Config not in recommended directory - Backend**
|
||||||
|
|
||||||
|
A configuration file is not in the recommended directory. This might lead to issues with the deployment method you are using. It might also lead to confusinon.
|
||||||
|
|
||||||
|
The warning text will show the recommended directory for your deployment method.
|
||||||
|
|
||||||
#### INVE-W10
|
#### INVE-W10
|
||||||
**Exception during mail delivery - Backend**
|
**Exception during mail delivery - Backend**
|
||||||
|
@ -8,6 +8,7 @@ import random
|
|||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
CONFIG_DATA = None
|
CONFIG_DATA = None
|
||||||
@ -68,6 +69,21 @@ def get_base_dir() -> Path:
|
|||||||
return Path(__file__).parent.parent.resolve()
|
return Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def get_root_dir() -> Path:
|
||||||
|
"""Returns the InvenTree root directory."""
|
||||||
|
return get_base_dir().parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_dir() -> Path:
|
||||||
|
"""Returns the InvenTree configuration directory."""
|
||||||
|
return get_root_dir().joinpath('config').resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def get_testfolder_dir() -> Path:
|
||||||
|
"""Returns the InvenTree test folder directory."""
|
||||||
|
return get_base_dir().joinpath('_testfolder').resolve()
|
||||||
|
|
||||||
|
|
||||||
def ensure_dir(path: Path, storage=None) -> None:
|
def ensure_dir(path: Path, storage=None) -> None:
|
||||||
"""Ensure that a directory exists.
|
"""Ensure that a directory exists.
|
||||||
|
|
||||||
@ -90,15 +106,19 @@ def get_config_file(create=True) -> Path:
|
|||||||
|
|
||||||
Note: It will be created it if does not already exist!
|
Note: It will be created it if does not already exist!
|
||||||
"""
|
"""
|
||||||
|
conf_dir = get_config_dir()
|
||||||
base_dir = get_base_dir()
|
base_dir = get_base_dir()
|
||||||
|
|
||||||
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
||||||
|
|
||||||
if cfg_filename:
|
if cfg_filename:
|
||||||
cfg_filename = Path(cfg_filename.strip()).resolve()
|
cfg_filename = Path(cfg_filename.strip()).resolve()
|
||||||
|
elif get_base_dir().joinpath('config.yaml').exists():
|
||||||
|
# If the config file is in the old directory, use that
|
||||||
|
cfg_filename = base_dir.joinpath('config.yaml').resolve()
|
||||||
else:
|
else:
|
||||||
# Config file is *not* specified - use the default
|
# Config file is *not* specified - use the default
|
||||||
cfg_filename = base_dir.joinpath('config.yaml').resolve()
|
cfg_filename = conf_dir.joinpath('config.yaml').resolve()
|
||||||
|
|
||||||
if not cfg_filename.exists() and create:
|
if not cfg_filename.exists() and create:
|
||||||
print(
|
print(
|
||||||
@ -110,6 +130,7 @@ def get_config_file(create=True) -> Path:
|
|||||||
shutil.copyfile(cfg_template, cfg_filename)
|
shutil.copyfile(cfg_template, cfg_filename)
|
||||||
print(f'Created config file {cfg_filename}')
|
print(f'Created config file {cfg_filename}')
|
||||||
|
|
||||||
|
check_config_dir('INVENTREE_CONFIG_FILE', cfg_filename, conf_dir)
|
||||||
return cfg_filename
|
return cfg_filename
|
||||||
|
|
||||||
|
|
||||||
@ -291,7 +312,7 @@ def get_backup_dir(create=True, error=True):
|
|||||||
return bd
|
return bd
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_file():
|
def get_plugin_file() -> Path:
|
||||||
"""Returns the path of the InvenTree plugins specification file.
|
"""Returns the path of the InvenTree plugins specification file.
|
||||||
|
|
||||||
Note: It will be created if it does not already exist!
|
Note: It will be created if it does not already exist!
|
||||||
@ -319,6 +340,7 @@ def get_plugin_file():
|
|||||||
'# InvenTree Plugins (uses PIP framework to install)\n\n'
|
'# InvenTree Plugins (uses PIP framework to install)\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
check_config_dir('INVENTREE_PLUGIN_FILE', plugin_file)
|
||||||
return plugin_file
|
return plugin_file
|
||||||
|
|
||||||
|
|
||||||
@ -327,7 +349,7 @@ def get_plugin_dir():
|
|||||||
return get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
|
return get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
|
||||||
|
|
||||||
|
|
||||||
def get_secret_key():
|
def get_secret_key(return_path: bool = False) -> Union[str, Path]:
|
||||||
"""Return the secret key value which will be used by django.
|
"""Return the secret key value which will be used by django.
|
||||||
|
|
||||||
Following options are tested, in descending order of preference:
|
Following options are tested, in descending order of preference:
|
||||||
@ -336,18 +358,24 @@ def get_secret_key():
|
|||||||
B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
|
B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
|
||||||
C) Look for default key file "secret_key.txt"
|
C) Look for default key file "secret_key.txt"
|
||||||
D) Create "secret_key.txt" if it does not exist
|
D) Create "secret_key.txt" if it does not exist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
return_path (bool): If True, return the path to the secret key file instead of the key data.
|
||||||
"""
|
"""
|
||||||
# Look for environment variable
|
# Look for environment variable
|
||||||
if secret_key := get_setting('INVENTREE_SECRET_KEY', 'secret_key'):
|
if secret_key := get_setting('INVENTREE_SECRET_KEY', 'secret_key'):
|
||||||
logger.info('SECRET_KEY loaded by INVENTREE_SECRET_KEY') # pragma: no cover
|
logger.info('SECRET_KEY loaded by INVENTREE_SECRET_KEY') # pragma: no cover
|
||||||
return secret_key
|
return str(secret_key)
|
||||||
|
|
||||||
# Look for secret key file
|
# Look for secret key file
|
||||||
if secret_key_file := get_setting('INVENTREE_SECRET_KEY_FILE', 'secret_key_file'):
|
if secret_key_file := get_setting('INVENTREE_SECRET_KEY_FILE', 'secret_key_file'):
|
||||||
secret_key_file = Path(secret_key_file).resolve()
|
secret_key_file = Path(secret_key_file).resolve()
|
||||||
|
elif get_base_dir().joinpath('secret_key.txt').exists():
|
||||||
|
secret_key_file = get_base_dir().joinpath('secret_key.txt')
|
||||||
else:
|
else:
|
||||||
# Default location for secret key file
|
# Default location for secret key file
|
||||||
secret_key_file = get_base_dir().joinpath('secret_key.txt').resolve()
|
secret_key_file = get_config_dir().joinpath('secret_key.txt').resolve()
|
||||||
|
check_config_dir('INVENTREE_SECRET_KEY_FILE', secret_key_file)
|
||||||
|
|
||||||
if not secret_key_file.exists():
|
if not secret_key_file.exists():
|
||||||
logger.info("Generating random key file at '%s'", secret_key_file)
|
logger.info("Generating random key file at '%s'", secret_key_file)
|
||||||
@ -358,14 +386,14 @@ def get_secret_key():
|
|||||||
key = ''.join([random.choice(options) for _idx in range(100)])
|
key = ''.join([random.choice(options) for _idx in range(100)])
|
||||||
secret_key_file.write_text(key)
|
secret_key_file.write_text(key)
|
||||||
|
|
||||||
|
if return_path:
|
||||||
|
return secret_key_file
|
||||||
|
|
||||||
logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
|
logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
|
||||||
|
return secret_key_file.read_text().strip()
|
||||||
key_data = secret_key_file.read_text().strip()
|
|
||||||
|
|
||||||
return key_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_oidc_private_key():
|
def get_oidc_private_key(return_path: bool = False) -> Union[str, Path]:
|
||||||
"""Return the private key for OIDC authentication.
|
"""Return the private key for OIDC authentication.
|
||||||
|
|
||||||
Following options are tested, in descending order of preference:
|
Following options are tested, in descending order of preference:
|
||||||
@ -383,11 +411,19 @@ def get_oidc_private_key():
|
|||||||
get_setting(
|
get_setting(
|
||||||
'INVENTREE_OIDC_PRIVATE_KEY_FILE',
|
'INVENTREE_OIDC_PRIVATE_KEY_FILE',
|
||||||
'oidc_private_key_file',
|
'oidc_private_key_file',
|
||||||
get_base_dir().joinpath('oidc.pem'),
|
get_config_dir().joinpath('oidc.pem'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Trying old default location
|
||||||
|
if not key_loc.exists():
|
||||||
|
old_def_path = get_base_dir().joinpath('oidc.pem')
|
||||||
|
if old_def_path.exists():
|
||||||
|
key_loc = old_def_path.resolve()
|
||||||
|
|
||||||
|
check_config_dir('INVENTREE_OIDC_PRIVATE_KEY_FILE', key_loc)
|
||||||
if key_loc.exists():
|
if key_loc.exists():
|
||||||
return key_loc.read_text()
|
return key_loc.read_text() if not return_path else key_loc
|
||||||
else:
|
else:
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
@ -407,8 +443,7 @@ def get_oidc_private_key():
|
|||||||
encryption_algorithm=serialization.NoEncryption(),
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
RSA_KEY = key_loc.read_text()
|
return key_loc.read_text() if not return_path else key_loc
|
||||||
return RSA_KEY
|
|
||||||
|
|
||||||
|
|
||||||
def get_custom_file(
|
def get_custom_file(
|
||||||
@ -494,3 +529,28 @@ def get_frontend_settings(debug=True):
|
|||||||
frontend_settings['url_compatibility'] = True
|
frontend_settings['url_compatibility'] = True
|
||||||
|
|
||||||
return frontend_settings
|
return frontend_settings
|
||||||
|
|
||||||
|
|
||||||
|
def check_config_dir(
|
||||||
|
setting_name: str, current_path: Path, config_dir: Optional[Path] = None
|
||||||
|
) -> None:
|
||||||
|
"""Warn if the config directory is not used."""
|
||||||
|
if not config_dir:
|
||||||
|
config_dir = get_config_dir()
|
||||||
|
|
||||||
|
if not current_path.is_relative_to(config_dir):
|
||||||
|
logger.warning(
|
||||||
|
"INVE-W10 - Config for '%s' not in recommended directory '%s'.",
|
||||||
|
setting_name,
|
||||||
|
config_dir,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from common.settings import GlobalWarningCode, set_global_warning
|
||||||
|
|
||||||
|
set_global_warning(
|
||||||
|
GlobalWarningCode.UNCOMMON_CONFIG, {'path': str(config_dir)}
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
return
|
||||||
|
@ -73,7 +73,7 @@ BASE_DIR = config.get_base_dir()
|
|||||||
CONFIG = config.load_config_data(set_cache=True)
|
CONFIG = config.load_config_data(set_cache=True)
|
||||||
|
|
||||||
# Load VERSION data if it exists
|
# Load VERSION data if it exists
|
||||||
version_file = BASE_DIR.parent.parent.parent.joinpath('VERSION')
|
version_file = config.get_root_dir().joinpath('VERSION')
|
||||||
if version_file.exists():
|
if version_file.exists():
|
||||||
load_dotenv(version_file)
|
load_dotenv(version_file)
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@ -1199,39 +1200,128 @@ class TestSettings(InvenTreeTestCase):
|
|||||||
"""Test get_config_file."""
|
"""Test get_config_file."""
|
||||||
# normal run - not configured
|
# normal run - not configured
|
||||||
|
|
||||||
valid = ['inventree/config.yaml', 'inventree/data/config.yaml']
|
valid = ['config/config.yaml', 'inventree/data/config.yaml']
|
||||||
|
|
||||||
|
trgt_path = str(config.get_config_file()).lower()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(opt in str(config.get_config_file()).lower() for opt in valid)
|
any(opt in trgt_path for opt in valid), f'Path {trgt_path} not in {valid}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with in_env_context({
|
test_file = config.get_testfolder_dir() / 'my_special_conf.yaml'
|
||||||
'INVENTREE_CONFIG_FILE': '_testfolder/my_special_conf.yaml'
|
with in_env_context({'INVENTREE_CONFIG_FILE': str(test_file)}):
|
||||||
}):
|
self.assertEqual(
|
||||||
self.assertIn(
|
str(test_file).lower(), str(config.get_config_file()).lower()
|
||||||
'inventree/_testfolder/my_special_conf.yaml',
|
|
||||||
str(config.get_config_file()).lower(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# LEGACY - old path
|
||||||
|
if settings.DOCKER: # pragma: no cover
|
||||||
|
# In Docker, the legacy path is not used
|
||||||
|
return
|
||||||
|
legacy_path = config.get_base_dir().joinpath('config.yaml')
|
||||||
|
assert not legacy_path.exists(), (
|
||||||
|
'Legacy config file does exist, stopping as a percaution!'
|
||||||
|
)
|
||||||
|
self.assertTrue(test_file.exists(), f'Test file {test_file} does not exist!')
|
||||||
|
test_file.rename(legacy_path)
|
||||||
|
self.assertIn(
|
||||||
|
'src/backend/inventree/config.yaml', str(config.get_config_file()).lower()
|
||||||
|
)
|
||||||
|
# Clean up again
|
||||||
|
legacy_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
def test_helpers_plugin_file(self):
|
def test_helpers_plugin_file(self):
|
||||||
"""Test get_plugin_file."""
|
"""Test get_plugin_file."""
|
||||||
# normal run - not configured
|
# normal run - not configured
|
||||||
|
|
||||||
valid = ['inventree/plugins.txt', 'inventree/data/plugins.txt']
|
valid = ['config/plugins.txt', 'inventree/data/plugins.txt']
|
||||||
|
|
||||||
|
trgt_path = str(config.get_plugin_file()).lower()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(opt in str(config.get_plugin_file()).lower() for opt in valid)
|
any(opt in trgt_path for opt in valid), f'Path {trgt_path} not in {valid}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with in_env_context({
|
test_file = config.get_testfolder_dir() / 'my_special_plugins.txt'
|
||||||
'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt'
|
with in_env_context({'INVENTREE_PLUGIN_FILE': str(test_file)}):
|
||||||
}):
|
self.assertIn(str(test_file), str(config.get_plugin_file()))
|
||||||
self.assertIn(
|
|
||||||
'_testfolder/my_special_plugins.txt', str(config.get_plugin_file())
|
def test_helpers_secret_key(self):
|
||||||
|
"""Test get_secret_key."""
|
||||||
|
# Normal file behavior - not configured
|
||||||
|
valid = ['config/secret_key.txt', 'inventree/data/secret_key.txt']
|
||||||
|
trgt_path = str(config.get_secret_key(return_path=True)).lower()
|
||||||
|
self.assertTrue(
|
||||||
|
any(opt in trgt_path for opt in valid), f'Path {trgt_path} not in {valid}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# with env set
|
||||||
|
test_file = config.get_testfolder_dir() / 'my_secret_test.txt'
|
||||||
|
with in_env_context({'INVENTREE_SECRET_KEY_FILE': str(test_file)}):
|
||||||
|
self.assertIn(str(test_file), str(config.get_secret_key(return_path=True)))
|
||||||
|
|
||||||
|
# LEGACY - old path
|
||||||
|
if settings.DOCKER: # pragma: no cover
|
||||||
|
# In Docker, the legacy path is not used
|
||||||
|
return
|
||||||
|
legacy_path = config.get_base_dir().joinpath('secret_key.txt')
|
||||||
|
assert not legacy_path.exists(), (
|
||||||
|
'Legacy secret key file does exist, stopping as a percaution!'
|
||||||
|
)
|
||||||
|
test_file.rename(legacy_path)
|
||||||
|
self.assertIn(
|
||||||
|
'src/backend/inventree/secret_key.txt',
|
||||||
|
str(config.get_secret_key(return_path=True)).lower(),
|
||||||
|
)
|
||||||
|
# Clean up again
|
||||||
|
legacy_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Test with content set per environment
|
||||||
|
with in_env_context({'INVENTREE_SECRET_KEY': '123abc123'}):
|
||||||
|
self.assertEqual(config.get_secret_key(), '123abc123')
|
||||||
|
|
||||||
|
def test_helpers_get_oidc_private_key(self):
|
||||||
|
"""Test get_oidc_private_key."""
|
||||||
|
# Normal file behavior - not configured
|
||||||
|
valid = ['config/oidc.pem', 'inventree/data/oidc.pem']
|
||||||
|
trgt_path = config.get_oidc_private_key(return_path=True)
|
||||||
|
self.assertTrue(
|
||||||
|
any(opt in str(trgt_path) for opt in valid),
|
||||||
|
f'Path {trgt_path} not in {valid}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# with env set
|
||||||
|
test_file = config.get_testfolder_dir() / 'my_oidc_private_key.pem'
|
||||||
|
with in_env_context({'INVENTREE_OIDC_PRIVATE_KEY_FILE': str(test_file)}):
|
||||||
|
self.assertIn(
|
||||||
|
str(test_file), str(config.get_oidc_private_key(return_path=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override with environment variable
|
||||||
|
with in_env_context({'INVENTREE_OIDC_PRIVATE_KEY': '123abc123'}):
|
||||||
|
self.assertEqual(config.get_oidc_private_key(), '123abc123')
|
||||||
|
|
||||||
|
# LEGACY - old path
|
||||||
|
if settings.DOCKER: # pragma: no cover
|
||||||
|
# In Docker, the legacy path is not used
|
||||||
|
return
|
||||||
|
legacy_path = config.get_base_dir().joinpath('oidc.pem')
|
||||||
|
assert not legacy_path.exists(), (
|
||||||
|
'Legacy OIDC private key file does exist, stopping as a precaution!'
|
||||||
|
)
|
||||||
|
test_file.rename(legacy_path)
|
||||||
|
assert isinstance(trgt_path, Path)
|
||||||
|
new_path = trgt_path.rename(
|
||||||
|
trgt_path.parent / '_oidc.pem'
|
||||||
|
) # move out current config
|
||||||
|
self.assertIn(
|
||||||
|
'src/backend/inventree/oidc.pem',
|
||||||
|
str(config.get_oidc_private_key(return_path=True)).lower(),
|
||||||
|
)
|
||||||
|
# Clean up again
|
||||||
|
legacy_path.unlink(missing_ok=True)
|
||||||
|
new_path.rename(trgt_path) # restore original path for current config
|
||||||
|
|
||||||
def test_helpers_setting(self):
|
def test_helpers_setting(self):
|
||||||
"""Test get_setting."""
|
"""Test get_setting."""
|
||||||
TEST_ENV_NAME = '123TEST'
|
TEST_ENV_NAME = '123TEST'
|
||||||
|
@ -162,6 +162,12 @@ class BaseURLValidator(URLValidator):
|
|||||||
super().__call__(value)
|
super().__call__(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetId:
|
||||||
|
"""Shared system settings identifiers."""
|
||||||
|
|
||||||
|
GLOBAL_WARNING = '_GLOBAL_WARNING'
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||||
'SERVER_RESTART_REQUIRED': {
|
'SERVER_RESTART_REQUIRED': {
|
||||||
'name': _('Restart required'),
|
'name': _('Restart required'),
|
||||||
@ -176,6 +182,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'default': 0,
|
'default': 0,
|
||||||
'validator': int,
|
'validator': int,
|
||||||
},
|
},
|
||||||
|
SystemSetId.GLOBAL_WARNING: {
|
||||||
|
'name': _('Active warning codes'),
|
||||||
|
'description': _('A dict of active warning codes'),
|
||||||
|
'validator': json.loads,
|
||||||
|
'default': '{}',
|
||||||
|
'hidden': True,
|
||||||
|
},
|
||||||
'INVENTREE_INSTANCE_ID': {
|
'INVENTREE_INSTANCE_ID': {
|
||||||
'name': _('Instance ID'),
|
'name': _('Instance ID'),
|
||||||
'description': _('Unique identifier for this InvenTree instance'),
|
'description': _('Unique identifier for this InvenTree instance'),
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
"""User-configurable settings for the common app."""
|
"""User-configurable settings for the common app."""
|
||||||
|
|
||||||
|
import json
|
||||||
from os import environ
|
from os import environ
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
import InvenTree.ready
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def global_setting_overrides() -> dict:
|
def global_setting_overrides() -> dict:
|
||||||
@ -41,6 +51,63 @@ def set_global_setting(key, value, change_user=None, create=True, **kwargs):
|
|||||||
return InvenTreeSetting.set_setting(key, value, **kwargs)
|
return InvenTreeSetting.set_setting(key, value, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalWarningCode:
|
||||||
|
"""Warning codes that reflect to the status of the instance."""
|
||||||
|
|
||||||
|
UNCOMMON_CONFIG = 'INVE-W10'
|
||||||
|
TEST_KEY = '_TEST'
|
||||||
|
|
||||||
|
|
||||||
|
def set_global_warning(key: str, options: Optional[dict] = None) -> bool:
|
||||||
|
"""Set a global warning for a code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): The key for the warning.
|
||||||
|
options (dict or bool): Options for the warning, or True to set a default warning.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the key is not provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the warning was checked / set successfully, False if no check was performed.
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
raise ValueError('Key must be provided for global warning setting.')
|
||||||
|
|
||||||
|
# Ensure DB is ready
|
||||||
|
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from common.setting.system import SystemSetId
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
# App registry not ready, cannot set global warning
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get and write (if necessary) the current global settings warning
|
||||||
|
global_dict = get_global_setting(SystemSetId.GLOBAL_WARNING, '{}', create=False)
|
||||||
|
try:
|
||||||
|
global_dict = json.loads(global_dict)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
global_dict = {}
|
||||||
|
if global_dict is None or not isinstance(global_dict, dict):
|
||||||
|
global_dict = {}
|
||||||
|
if key not in global_dict or global_dict[key] != options:
|
||||||
|
global_dict[key] = options if options is not None else True
|
||||||
|
try:
|
||||||
|
global_dict_val = json.dumps(global_dict)
|
||||||
|
except TypeError:
|
||||||
|
# If the options cannot be serialized, we will set an empty the warning
|
||||||
|
global_dict_val = 'true'
|
||||||
|
logger.warning(
|
||||||
|
f'Failed to serialize global warning options for key "{key}". Setting to True.'
|
||||||
|
)
|
||||||
|
InvenTreeSetting.set_setting(
|
||||||
|
SystemSetId.GLOBAL_WARNING, global_dict_val, change_user=None, create=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def stock_expiry_enabled():
|
def stock_expiry_enabled():
|
||||||
"""Returns True if the stock expiry feature is enabled."""
|
"""Returns True if the stock expiry feature is enabled."""
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
@ -509,6 +509,44 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
value = InvenTreeUserSetting.get_setting(key, user=user)
|
value = InvenTreeUserSetting.get_setting(key, user=user)
|
||||||
self.assertEqual(value, user.pk)
|
self.assertEqual(value, user.pk)
|
||||||
|
|
||||||
|
def test_set_global_warning(self):
|
||||||
|
"""Test set_global_warning function."""
|
||||||
|
from common.setting.system import SystemSetId
|
||||||
|
from common.settings import GlobalWarningCode, set_global_warning
|
||||||
|
|
||||||
|
# Set a warning
|
||||||
|
self.assertTrue(
|
||||||
|
set_global_warning(GlobalWarningCode.TEST_KEY, {'test': 'value'})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the warning has been set
|
||||||
|
self.assertIn(
|
||||||
|
GlobalWarningCode.TEST_KEY,
|
||||||
|
InvenTreeSetting.get_setting(SystemSetId.GLOBAL_WARNING),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the warning can be retrieved
|
||||||
|
warning = InvenTreeSetting.get_setting_object(SystemSetId.GLOBAL_WARNING)
|
||||||
|
warning_dict = json.loads(warning.value)
|
||||||
|
self.assertEqual(warning_dict[GlobalWarningCode.TEST_KEY], {'test': 'value'})
|
||||||
|
|
||||||
|
# Clear the warning
|
||||||
|
self.assertTrue(set_global_warning(GlobalWarningCode.TEST_KEY, False))
|
||||||
|
|
||||||
|
# Check that the warning has been cleared
|
||||||
|
self.assertFalse(
|
||||||
|
json.loads(InvenTreeSetting.get_setting(SystemSetId.GLOBAL_WARNING)).get(
|
||||||
|
GlobalWarningCode.TEST_KEY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# No key - warning
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
set_global_warning(None)
|
||||||
|
|
||||||
|
# Wrong structure
|
||||||
|
self.assertTrue(set_global_warning(GlobalWarningCode.TEST_KEY, {'test': json}))
|
||||||
|
|
||||||
|
|
||||||
class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
||||||
"""Tests for the global settings API."""
|
"""Tests for the global settings API."""
|
||||||
|
@ -20,7 +20,7 @@ import order.models
|
|||||||
from build.status_codes import BuildStatus
|
from build.status_codes import BuildStatus
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from InvenTree.settings import BASE_DIR
|
from InvenTree.config import get_testfolder_dir
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from order.status_codes import PurchaseOrderStatusGroups
|
from order.status_codes import PurchaseOrderStatusGroups
|
||||||
from part.models import (
|
from part.models import (
|
||||||
@ -64,7 +64,7 @@ class PartImageTestMixin:
|
|||||||
"""Create a test image file."""
|
"""Create a test image file."""
|
||||||
p = Part.objects.first()
|
p = Part.objects.first()
|
||||||
|
|
||||||
fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png'
|
fn = get_testfolder_dir() / 'part_image_123abc.png'
|
||||||
|
|
||||||
img = PIL.Image.new('RGB', (128, 128), color='blue')
|
img = PIL.Image.new('RGB', (128, 128), color='blue')
|
||||||
img.save(fn)
|
img.save(fn)
|
||||||
@ -1694,7 +1694,7 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
|||||||
print(p.image.file)
|
print(p.image.file)
|
||||||
|
|
||||||
# Try to upload a non-image file
|
# Try to upload a non-image file
|
||||||
test_path = BASE_DIR / '_testfolder' / 'dummy_image'
|
test_path = get_testfolder_dir() / 'dummy_image'
|
||||||
with open(f'{test_path}.txt', 'w', encoding='utf-8') as dummy_image:
|
with open(f'{test_path}.txt', 'w', encoding='utf-8') as dummy_image:
|
||||||
dummy_image.write('hello world')
|
dummy_image.write('hello world')
|
||||||
|
|
||||||
@ -1757,7 +1757,7 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
|||||||
# First, upload an image for an existing part
|
# First, upload an image for an existing part
|
||||||
p = Part.objects.first()
|
p = Part.objects.first()
|
||||||
|
|
||||||
fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png'
|
fn = get_testfolder_dir() / 'part_image_123abc.png'
|
||||||
|
|
||||||
img = PIL.Image.new('RGB', (128, 128), color='blue')
|
img = PIL.Image.new('RGB', (128, 128), color='blue')
|
||||||
img.save(fn)
|
img.save(fn)
|
||||||
|
@ -10,7 +10,7 @@ from django.urls import reverse
|
|||||||
from pdfminer.high_level import extract_text
|
from pdfminer.high_level import extract_text
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from InvenTree.settings import BASE_DIR
|
from InvenTree.config import get_testfolder_dir
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from plugin import InvenTreePlugin, PluginMixinEnum, registry
|
from plugin import InvenTreePlugin, PluginMixinEnum, registry
|
||||||
@ -90,7 +90,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
|||||||
apps.get_app_config('report').create_default_labels()
|
apps.get_app_config('report').create_default_labels()
|
||||||
apps.get_app_config('report').create_default_reports()
|
apps.get_app_config('report').create_default_reports()
|
||||||
|
|
||||||
test_path = BASE_DIR / '_testfolder' / 'label'
|
test_path = get_testfolder_dir() / 'label'
|
||||||
|
|
||||||
parts = Part.objects.all()[:2]
|
parts = Part.objects.all()[:2]
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ This does not function in real usage and is more to show the required components
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.settings import BASE_DIR
|
from InvenTree.config import get_testfolder_dir
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import LabelPrintingMixin
|
from plugin.mixins import LabelPrintingMixin
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
|||||||
kwargs['label_instance'], kwargs['item_instance'], **kwargs
|
kwargs['label_instance'], kwargs['item_instance'], **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
filename = str(BASE_DIR / '_testfolder' / 'label.pdf')
|
filename = str(get_testfolder_dir() / 'label.pdf')
|
||||||
|
|
||||||
# Dump the PDF to a local file
|
# Dump the PDF to a local file
|
||||||
with open(filename, 'wb') as pdf_out:
|
with open(filename, 'wb') as pdf_out:
|
||||||
|
@ -12,6 +12,7 @@ from djmoney.money import Money
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
from InvenTree.config import get_testfolder_dir
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from part.models import Part, PartParameter, PartParameterTemplate
|
from part.models import Part, PartParameter, PartParameterTemplate
|
||||||
from part.test_api import PartImageTestMixin
|
from part.test_api import PartImageTestMixin
|
||||||
@ -309,7 +310,7 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
def test_encode_svg_image(self):
|
def test_encode_svg_image(self):
|
||||||
"""Test the encode_svg_image template tag."""
|
"""Test the encode_svg_image template tag."""
|
||||||
# Generate smallest possible SVG for testing
|
# Generate smallest possible SVG for testing
|
||||||
svg_path = settings.BASE_DIR / '_testfolder' / 'part_image_123abc.png'
|
svg_path = get_testfolder_dir() / 'part_image_123abc.png'
|
||||||
with open(svg_path, 'w', encoding='utf8') as f:
|
with open(svg_path, 'w', encoding='utf8') as f:
|
||||||
f.write('<svg xmlns="http://www.w3.org/2000/svg>')
|
f.write('<svg xmlns="http://www.w3.org/2000/svg>')
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user