Coverage for src/backend/InvenTree/InvenTree/config.py: 63%
293 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Helper functions for loading InvenTree configuration options."""
3import datetime
4import json
5import logging
6import os
7import random
8import shutil
9import string
10import sys
11from pathlib import Path
12from typing import Any, Optional
14logger = logging.getLogger('inventree')
15CONFIG_DATA = None
16CONFIG_LOOKUPS = {}
19def to_list(value, delimiter=','):
20 """Take a configuration setting and make sure it is a list.
22 For example, we might have a configuration setting taken from the .config file,
23 which is already a list.
25 However, the same setting may be specified via an environment variable,
26 using a comma delimited string!
27 """
28 if type(value) in [list, tuple]:
29 return value
31 # Otherwise, force string value
32 value = str(value)
34 return [x.strip() for x in value.split(delimiter)]
37def to_dict(value):
38 """Take a configuration setting and make sure it is a dict.
40 For example, we might have a configuration setting taken from the .config file,
41 which is already an object/dict.
43 However, the same setting may be specified via an environment variable,
44 using a valid JSON string!
45 """
46 if value is None:
47 return {}
49 if isinstance(value, dict): 49 ↛ 52line 49 didn't jump to line 52 because the condition on line 49 was always true
50 return value
52 try:
53 return json.loads(value)
54 except Exception as error:
55 logger.exception(
56 "Failed to parse value '%s' as JSON with error %s. Ensure value is a valid JSON string.",
57 value,
58 error,
59 )
60 return {}
63def is_true(x):
64 """Shortcut function to determine if a value "looks" like a boolean."""
65 return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
68def get_base_dir() -> Path:
69 """Returns the base (top-level) InvenTree directory."""
70 return Path(__file__).parent.parent.resolve()
73def get_root_dir() -> Path:
74 """Returns the InvenTree root directory."""
75 return get_base_dir().parent.parent.parent
78def inventreeInstaller() -> Optional[str]:
79 """Returns the installer for the running codebase - if set or detectable."""
80 load_version_file()
82 # First look in the environment variables, e.g. if running in docker
83 installer = os.environ.get('INVENTREE_PKG_INSTALLER', '')
85 if installer: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 return str(installer)
88 if is_true(os.environ.get('INVENTREE_DEVCONTAINER', 'False')): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 return 'DEV'
91 if is_true(os.environ.get('INVENTREE_DOCKER', 'False')): 91 ↛ 94line 91 didn't jump to line 94 because the condition on line 91 was always true
92 return 'DOC'
94 try:
95 from django.conf import settings
97 from InvenTree.version import main_commit
99 if settings.DOCKER:
100 return 'DOC'
101 elif main_commit is not None:
102 return 'GIT'
103 except Exception:
104 pass
105 return None
108def get_config_dir() -> Path:
109 """Returns the InvenTree configuration directory depending on the install type."""
110 if inst := inventreeInstaller(): 110 ↛ 118line 110 didn't jump to line 118 because the condition on line 110 was always true
111 if inst == 'DOC': 111 ↛ 113line 111 didn't jump to line 113 because the condition on line 111 was always true
112 return Path('/home/inventree/data/').resolve()
113 elif inst == 'DEV':
114 return Path('/home/inventree/dev/').resolve()
115 elif inst == 'PKG':
116 return Path('/etc/inventree/').resolve()
118 return get_root_dir().joinpath('config').resolve()
121def get_testfolder_dir() -> Path:
122 """Returns the InvenTree test folder directory."""
123 return get_base_dir().joinpath('_testfolder').resolve()
126def get_version_file() -> Path:
127 """Returns the path of the InvenTree VERSION file. This does not ensure that the file exists."""
128 return get_root_dir().joinpath('VERSION').resolve()
131def ensure_dir(path: Path, storage=None) -> None:
132 """Ensure that a directory exists.
134 If it does not exist, create it.
135 """
136 from django.core.files.base import ContentFile
137 from django.core.files.storage import Storage
139 if storage and isinstance(storage, Storage): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 if not storage.exists(str(path)):
141 storage.save(str(path / '.empty'), ContentFile(''))
142 return
144 if not path.exists(): 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 path.mkdir(parents=True, exist_ok=True)
148def get_config_file(create=True) -> Path:
149 """Returns the path of the InvenTree configuration file.
151 Note: It will be created it if does not already exist!
152 """
153 conf_dir = get_config_dir()
154 base_dir = get_base_dir()
156 cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
158 if cfg_filename: 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was always true
159 cfg_filename = Path(cfg_filename.strip()).resolve()
160 elif get_base_dir().joinpath('config.yaml').exists():
161 # If the config file is in the old directory, use that
162 cfg_filename = base_dir.joinpath('config.yaml').resolve()
163 else:
164 # Config file is *not* specified - use the default
165 cfg_filename = conf_dir.joinpath('config.yaml').resolve()
167 if not cfg_filename.exists() and create: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 print(
169 "InvenTree configuration file 'config.yaml' not found - creating default file"
170 )
171 ensure_dir(cfg_filename.parent)
173 cfg_template = base_dir.joinpath('config_template.yaml')
174 shutil.copyfile(cfg_template, cfg_filename)
175 print(f'Created config file {cfg_filename}')
177 check_config_dir('INVENTREE_CONFIG_FILE', cfg_filename, conf_dir)
178 return cfg_filename
181def load_config_data(set_cache: bool = False) -> map | None:
182 """Load configuration data from the config file.
184 Arguments:
185 set_cache(bool): If True, the configuration data will be cached for future use after load.
186 """
187 global CONFIG_DATA
189 # use cache if populated
190 # skip cache if cache should be set
191 if CONFIG_DATA is not None and not set_cache:
192 return CONFIG_DATA
194 import yaml.parser
196 cfg_file = get_config_file()
198 with open(cfg_file, encoding='utf-8') as cfg:
199 try:
200 data = yaml.safe_load(cfg)
201 except yaml.parser.ParserError as error:
202 logger.error(
203 "INVE-E13: Error reading InvenTree configuration file '%s': %s",
204 cfg_file,
205 error,
206 )
207 sys.exit(1)
209 # Set the cache if requested
210 if set_cache: 210 ↛ 213line 210 didn't jump to line 213 because the condition on line 210 was always true
211 CONFIG_DATA = data
213 return data
216def do_typecast(value, type, var_name=None):
217 """Attempt to typecast a value.
219 Arguments:
220 value: Value to typecast
221 type: Function to use for typecasting the value e.g. int, float, str, list, dict
222 var_name: Name that should be logged e.g. 'INVENTREE_STATIC_ROOT'. Set if logging is required.
224 Returns:
225 Typecast value or original value if typecasting failed.
226 """
227 # Force 'list' of strings
228 if type is list:
229 value = to_list(value)
231 # Valid JSON string is required
232 elif type is dict:
233 value = to_dict(value)
235 # Special handling for boolean typecasting
236 elif type is bool:
237 val = is_true(value)
238 return val
240 elif type is not None:
241 # Try to typecast the value
242 try:
243 val = type(value)
244 return val
245 except Exception as error:
246 if var_name:
247 logger.exception(
248 "Failed to typecast '%s' with value '%s' to type '%s' with error %s",
249 var_name,
250 value,
251 type,
252 error,
253 )
254 return value
257def get_config_value(config_key: str) -> Optional[Any]:
258 """Helper function to retrieve a configuration value from the config file."""
259 cfg_data = load_config_data()
261 result = None
263 # Hack to allow 'path traversal' in configuration file
264 for key in config_key.strip().split('.'):
265 if type(cfg_data) is not dict or key not in cfg_data:
266 result = None
267 break
269 result = cfg_data[key]
270 cfg_data = cfg_data[key]
272 return result
275def get_setting(env_var=None, config_key=None, default_value=None, typecast=None):
276 """Helper function for retrieving a configuration setting value.
278 - First preference is to look for the environment variable
279 - Second preference is to look for the value of the settings file
280 - Third preference is the default value
282 Arguments:
283 env_var: Name of the environment variable e.g. 'INVENTREE_STATIC_ROOT'
284 config_key: Key to lookup in the configuration file
285 default_value: Value to return if first two options are not provided
286 typecast: Function to use for typecasting the value e.g. int, float, str, list, dict
287 """
289 def set_metadata(source: str):
290 """Set lookup metadata for the setting."""
291 global CONFIG_LOOKUPS
293 key = env_var or config_key
294 CONFIG_LOOKUPS[key] = {
295 'env_var': env_var,
296 'config_key': config_key,
297 'default_value': default_value,
298 'source': source,
299 'accessed': datetime.datetime.now(),
300 }
302 # First, try to load from the environment variables
303 if env_var is not None: 303 ↛ 311line 303 didn't jump to line 311 because the condition on line 303 was always true
304 val = os.getenv(env_var, None)
306 if val is not None:
307 set_metadata('env')
308 return do_typecast(val, typecast, var_name=env_var)
310 # Next, try to load from configuration file
311 if config_key is not None:
312 result = get_config_value(config_key)
314 if result is not None:
315 set_metadata('yaml')
316 return do_typecast(result, typecast, var_name=env_var)
318 # Finally, return the default value
319 set_metadata('default')
320 return do_typecast(default_value, typecast, var_name=env_var)
323def get_boolean_setting(env_var=None, config_key=None, default_value=False):
324 """Helper function for retrieving a boolean configuration setting."""
325 return is_true(get_setting(env_var, config_key, default_value))
328def get_media_dir(create=True, error=True):
329 """Return the absolute path for the 'media' directory (where uploaded files are stored)."""
330 md = get_setting('INVENTREE_MEDIA_ROOT', 'media_root')
332 if not md: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 if error:
334 raise FileNotFoundError('INVENTREE_MEDIA_ROOT not specified')
335 else:
336 return None
338 md = Path(md).resolve()
340 if create: 340 ↛ 343line 340 didn't jump to line 343 because the condition on line 340 was always true
341 md.mkdir(parents=True, exist_ok=True)
343 return md
346def get_static_dir(create=True, error=True):
347 """Return the absolute path for the 'static' directory (where static files are stored)."""
348 sd = get_setting('INVENTREE_STATIC_ROOT', 'static_root')
350 if not sd: 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true
351 if error:
352 raise FileNotFoundError('INVENTREE_STATIC_ROOT not specified')
353 else:
354 return None
356 sd = Path(sd).resolve()
358 if create: 358 ↛ 361line 358 didn't jump to line 361 because the condition on line 358 was always true
359 sd.mkdir(parents=True, exist_ok=True)
361 return sd
364def get_backup_dir(create=True, error=True):
365 """Return the absolute path for the backup directory."""
366 bd = get_setting('INVENTREE_BACKUP_DIR', 'backup_dir')
368 if not bd: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 if error:
370 raise FileNotFoundError('INVENTREE_BACKUP_DIR not specified')
371 else:
372 return None
374 bd = Path(bd).resolve()
376 if create: 376 ↛ 379line 376 didn't jump to line 379 because the condition on line 376 was always true
377 bd.mkdir(parents=True, exist_ok=True)
379 return bd
382def get_plugin_file() -> Path:
383 """Returns the path of the InvenTree plugins specification file.
385 Note: It will be created if it does not already exist!
386 """
387 # Check if the plugin.txt file (specifying required plugins) is specified
388 plugin_file = get_setting('INVENTREE_PLUGIN_FILE', 'plugin_file')
390 if not plugin_file: 390 ↛ 392line 390 didn't jump to line 392 because the condition on line 390 was never true
391 # If not specified, look in the same directory as the configuration file
392 config_dir = get_config_file().parent
393 plugin_file = config_dir.joinpath('plugins.txt')
394 else:
395 # Make sure we are using a modern Path object
396 plugin_file = Path(plugin_file)
398 if not plugin_file.exists(): 398 ↛ 410line 398 didn't jump to line 410 because the condition on line 398 was always true
399 logger.warning(
400 'Plugin configuration file does not exist - creating default file'
401 )
402 logger.info("Creating plugin file at '%s'", plugin_file)
403 ensure_dir(plugin_file.parent)
405 # If opening the file fails (no write permission, for example), then this will throw an error
406 plugin_file.write_text(
407 '# InvenTree Plugins (uses PIP framework to install)\n\n'
408 )
410 check_config_dir('INVENTREE_PLUGIN_FILE', plugin_file)
411 return plugin_file
414def get_plugin_dir():
415 """Returns the path of the custom plugins directory."""
416 return get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir')
419def get_secret_key(return_path: bool = False) -> str | Path:
420 """Return the secret key value which will be used by django.
422 Following options are tested, in descending order of preference:
424 A) Check for environment variable INVENTREE_SECRET_KEY => Use raw key data
425 B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
426 C) Look for default key file "secret_key.txt"
427 D) Create "secret_key.txt" if it does not exist
429 Args:
430 return_path (bool): If True, return the path to the secret key file instead of the key data.
431 """
432 # Look for environment variable
433 if secret_key := get_setting('INVENTREE_SECRET_KEY', 'secret_key'): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 logger.info('SECRET_KEY loaded by INVENTREE_SECRET_KEY') # pragma: no cover
435 return str(secret_key)
437 # Look for secret key file
438 if secret_key_file := get_setting('INVENTREE_SECRET_KEY_FILE', 'secret_key_file'): 438 ↛ 440line 438 didn't jump to line 440 because the condition on line 438 was always true
439 secret_key_file = Path(secret_key_file).resolve()
440 elif get_base_dir().joinpath('secret_key.txt').exists():
441 secret_key_file = get_base_dir().joinpath('secret_key.txt')
442 else:
443 # Default location for secret key file
444 secret_key_file = get_config_dir().joinpath('secret_key.txt').resolve()
445 check_config_dir('INVENTREE_SECRET_KEY_FILE', secret_key_file)
447 if not secret_key_file.exists(): 447 ↛ 456line 447 didn't jump to line 456 because the condition on line 447 was always true
448 logger.info("Generating random key file at '%s'", secret_key_file)
449 ensure_dir(secret_key_file.parent)
451 # Create a random key file
452 options = string.digits + string.ascii_letters + string.punctuation
453 key = ''.join([random.choice(options) for _idx in range(100)])
454 secret_key_file.write_text(key)
456 if return_path: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true
457 return secret_key_file
459 logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
460 return secret_key_file.read_text().strip()
463def get_oidc_private_key(return_path: bool = False) -> str | Path:
464 """Return the private key for OIDC authentication.
466 Following options are tested, in descending order of preference:
467 A) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY or config yalue => Use raw key data
468 B) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY_FILE or config value => Load key data from file
469 C) Create "oidc.pem" if it does not exist
470 """
471 RSA_KEY = get_setting('INVENTREE_OIDC_PRIVATE_KEY', 'oidc_private_key')
472 if RSA_KEY: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 logger.info('RSA_KEY loaded by INVENTREE_OIDC_PRIVATE_KEY') # pragma: no cover
474 return RSA_KEY
476 # Look for private key file
477 key_loc = Path(
478 get_setting(
479 'INVENTREE_OIDC_PRIVATE_KEY_FILE',
480 'oidc_private_key_file',
481 get_config_dir().joinpath('oidc.pem'),
482 )
483 )
485 # Trying old default location
486 if not key_loc.exists(): 486 ↛ 491line 486 didn't jump to line 491 because the condition on line 486 was always true
487 old_def_path = get_base_dir().joinpath('oidc.pem')
488 if old_def_path.exists(): 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 key_loc = old_def_path.resolve()
491 check_config_dir('INVENTREE_OIDC_PRIVATE_KEY_FILE', key_loc)
492 if key_loc.exists(): 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 return key_loc.read_text() if not return_path else key_loc
494 else:
495 from cryptography.hazmat.primitives import serialization
496 from cryptography.hazmat.primitives.asymmetric import rsa
498 # Default location for private key file
499 logger.info("Generating oidc key file at '%s'", key_loc)
500 ensure_dir(key_loc.parent)
502 # Create a random key file
503 new_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
504 # Write our key to disk for safe keeping
505 with open(str(key_loc), 'wb') as f:
506 f.write(
507 new_key.private_bytes(
508 encoding=serialization.Encoding.PEM,
509 format=serialization.PrivateFormat.TraditionalOpenSSL,
510 encryption_algorithm=serialization.NoEncryption(),
511 )
512 )
513 return key_loc.read_text() if not return_path else key_loc
516def get_custom_file(
517 env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False
518):
519 """Returns the checked path to a custom file.
521 Set lookup_media to True to also search in the media folder.
522 """
523 from django.contrib.staticfiles.storage import StaticFilesStorage
524 from django.core.files.storage import default_storage
526 value = get_setting(env_ref, conf_ref, None)
528 if not value:
529 return None
531 static_storage = StaticFilesStorage()
533 if static_storage.exists(value):
534 logger.info('Loading %s from %s directory: %s', log_ref, 'static', value)
535 elif lookup_media and default_storage.exists(value):
536 logger.info('Loading %s from %s directory: %s', log_ref, 'media', value)
537 else:
538 add_dir_str = ' or media' if lookup_media else ''
539 logger.warning(
540 "The %s file '%s' could not be found in the static %s directories",
541 log_ref,
542 value,
543 add_dir_str,
544 )
545 value = False
547 return value
550def get_frontend_settings(debug=True):
551 """Return a dictionary of settings for the frontend interface."""
552 # New settings
553 frontend_settings = get_setting(
554 'INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict
555 )
557 base_url = get_setting(
558 'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'web', typecast=str
559 )
561 # Set the base URL for the user interface
562 # This is the UI path e.g. '/web/'
563 if 'base_url' not in frontend_settings: 563 ↛ 567line 563 didn't jump to line 567 because the condition on line 563 was always true
564 frontend_settings['base_url'] = base_url
566 # If provided, specify the API host
567 api_host = frontend_settings.get('api_host', None) or get_setting(
568 'INVENTREE_FRONTEND_API_HOST', 'frontend_api_host', None
569 )
571 if api_host: 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true
572 frontend_settings['api_host'] = api_host
574 # Set the server list
575 frontend_settings['server_list'] = frontend_settings.get('server_list', [])
577 # Set the debug flag
578 frontend_settings['debug'] = debug
580 if 'environment' not in frontend_settings: 580 ↛ 583line 580 didn't jump to line 583 because the condition on line 580 was always true
581 frontend_settings['environment'] = 'development' if debug else 'production'
583 if (debug and 'show_server_selector' not in frontend_settings) or len( 583 ↛ 591line 583 didn't jump to line 591 because the condition on line 583 was always true
584 frontend_settings['server_list']
585 ) == 0:
586 # In debug mode, show server selector by default
587 # If no servers are specified, show server selector
588 frontend_settings['show_server_selector'] = True
590 # Support compatibility with "legacy" URLs?
591 try:
592 frontend_settings['url_compatibility'] = bool(
593 frontend_settings.get('url_compatibility', True)
594 )
595 except Exception:
596 # If the value is not a boolean, set it to True
597 frontend_settings['url_compatibility'] = True
599 return frontend_settings
602def check_config_dir(
603 setting_name: str, current_path: Path, config_dir: Optional[Path] = None
604) -> None:
605 """Warn if the config directory is not used."""
606 if not config_dir:
607 config_dir = get_config_dir()
609 if not current_path.is_relative_to(config_dir): 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true
610 logger.warning(
611 "INVE-W10 - Config for '%s' not in recommended directory '%s'.",
612 setting_name,
613 config_dir,
614 )
616 try:
617 from common.settings import GlobalWarningCode, set_global_warning
619 set_global_warning(
620 GlobalWarningCode.UNCOMMON_CONFIG, {'path': str(config_dir)}
621 )
622 except ModuleNotFoundError: # pragma: no cover
623 pass
625 return
628VERSION_LOADED = False
629"""Flag to indicate if the VERSION file has been loaded in this process."""
632def load_version_file():
633 """Load the VERSION file if it exists and place the contents into the general execution environment.
635 Returns:
636 True if the VERSION file was loaded (now or previously), False otherwise.
637 """
638 global VERSION_LOADED
639 if VERSION_LOADED: 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true
640 return True
642 # Load the VERSION file if it exists
643 from dotenv import load_dotenv
645 version_file = get_version_file()
646 if version_file.exists(): 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true
647 load_dotenv(version_file)
648 VERSION_LOADED = True
649 return True
650 return False