mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-15 11:35:41 +00:00
[Feature] Override global settings (#9718)
* Provide mechanism for config to override global settings * Ensure overrides are observed on save * Refactor * Add "read_only" field to serializer * Prevent editing for read_only settings * Bump API version * Update docs * Secure logs * Override applies to default_value * override get_setting method * Add unit test * Utilize new approach to override SITE_URL * Docs updates * Docs tweaks * Shortcut for get_global_setting * Remove previous change - Allow validation to be performed within the InvenTreeSetting class * Override INVENTREE_BASE_URL setting * Handle error on worker boot * Tweak unit test
This commit is contained in:
@ -244,3 +244,11 @@ Refer to the [return order settings](../sales/return_order.md#return-order-setti
|
|||||||
### Project Codes
|
### Project Codes
|
||||||
|
|
||||||
Refer to the [project code settings](../concepts/project_codes.md).
|
Refer to the [project code settings](../concepts/project_codes.md).
|
||||||
|
|
||||||
|
## Override Global Settings
|
||||||
|
|
||||||
|
If desired, specific *global settings* can be overridden via environment variables or the configuration file.
|
||||||
|
|
||||||
|
Overriding a global setting will mark the setting as "read-only" in the user interface, and prevent it from being changed by user action. Additionally, the associated value stored in the database will be set to the value specified in the override.
|
||||||
|
|
||||||
|
Refer to the [configuration guide](../start/config.md#override-global-settings) for more information on how to override global settings.
|
||||||
|
@ -463,3 +463,13 @@ The following [plugin](../plugins/index.md) configuration options are available:
|
|||||||
| INVENTREE_PLUGIN_NOINSTALL | plugin_noinstall | Disable Plugin installation via API - only use plugins.txt file | False |
|
| INVENTREE_PLUGIN_NOINSTALL | plugin_noinstall | Disable Plugin installation via API - only use plugins.txt file | False |
|
||||||
| INVENTREE_PLUGIN_FILE | plugins_plugin_file | Location of plugin installation file | *Not specified* |
|
| INVENTREE_PLUGIN_FILE | plugins_plugin_file | Location of plugin installation file | *Not specified* |
|
||||||
| INVENTREE_PLUGIN_DIR | plugins_plugin_dir | Location of external plugin directory | *Not specified* |
|
| INVENTREE_PLUGIN_DIR | plugins_plugin_dir | Location of external plugin directory | *Not specified* |
|
||||||
|
|
||||||
|
## Override Global Settings
|
||||||
|
|
||||||
|
If required, [global settings values](../settings/global.md#override-global-settings) can be overridden by the system administrator.
|
||||||
|
|
||||||
|
To override global settings, provide a "dictionary" of settings overrides in the configuration file, or via an environment variable.
|
||||||
|
|
||||||
|
| Environment Variable | Configuration File | Description | Default |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GLOBAL_SETTINGS_OVERRIDES | global_settings_overrides | JSON object containing global settings overrides | *Not specified* |
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 345
|
INVENTREE_API_VERSION = 346
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v346 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9718
|
||||||
|
- Adds "read_only" field to the GlobalSettings API endpoint(s)
|
||||||
|
|
||||||
v345 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9745
|
v345 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9745
|
||||||
- Adds barcode information to SalesOrderShipment API endpoint
|
- Adds barcode information to SalesOrderShipment API endpoint
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@ from allauth.socialaccount.signals import social_account_updated
|
|||||||
import InvenTree.conversion
|
import InvenTree.conversion
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from common.settings import get_global_setting, set_global_setting
|
|
||||||
from InvenTree.config import get_setting
|
from InvenTree.config import get_setting
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -255,7 +254,6 @@ class InvenTreeConfig(AppConfig):
|
|||||||
def update_site_url(self):
|
def update_site_url(self):
|
||||||
"""Update the site URL setting.
|
"""Update the site URL setting.
|
||||||
|
|
||||||
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
|
|
||||||
- If multi-site support is enabled, update the site URL for the current site
|
- If multi-site support is enabled, update the site URL for the current site
|
||||||
"""
|
"""
|
||||||
if not InvenTree.ready.canAppAccessDatabase():
|
if not InvenTree.ready.canAppAccessDatabase():
|
||||||
@ -265,21 +263,15 @@ class InvenTreeConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if settings.SITE_URL:
|
if settings.SITE_URL:
|
||||||
try:
|
|
||||||
if get_global_setting('INVENTREE_BASE_URL') != settings.SITE_URL:
|
|
||||||
set_global_setting('INVENTREE_BASE_URL', settings.SITE_URL)
|
|
||||||
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If multi-site support is enabled, update the site URL for the current site
|
# If multi-site support is enabled, update the site URL for the current site
|
||||||
try:
|
try:
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
site = Site.objects.get_current()
|
site = Site.objects.get_current()
|
||||||
|
|
||||||
|
if site and site.domain != settings.SITE_URL:
|
||||||
site.domain = settings.SITE_URL
|
site.domain = settings.SITE_URL
|
||||||
site.save()
|
site.save()
|
||||||
|
|
||||||
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@ -1397,6 +1397,22 @@ TESTING_TABLE_EVENTS = False
|
|||||||
# Flag to allow pricing recalculations during testing
|
# Flag to allow pricing recalculations during testing
|
||||||
TESTING_PRICING = False
|
TESTING_PRICING = False
|
||||||
|
|
||||||
|
# Global settings overrides
|
||||||
|
# If provided, these values will override any "global" settings (and prevent them from being set)
|
||||||
|
GLOBAL_SETTINGS_OVERRIDES = get_setting(
|
||||||
|
'INVENTREE_GLOBAL_SETTINGS', 'global_settings', typecast=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override site URL setting
|
||||||
|
if SITE_URL:
|
||||||
|
GLOBAL_SETTINGS_OVERRIDES['INVENTREE_BASE_URL'] = SITE_URL
|
||||||
|
|
||||||
|
if len(GLOBAL_SETTINGS_OVERRIDES) > 0:
|
||||||
|
logger.info('Global settings overrides: %s', str(GLOBAL_SETTINGS_OVERRIDES))
|
||||||
|
for key in GLOBAL_SETTINGS_OVERRIDES:
|
||||||
|
# Set the global setting
|
||||||
|
logger.debug('- Override value for %s = ********', key)
|
||||||
|
|
||||||
# User interface customization values
|
# User interface customization values
|
||||||
CUSTOM_LOGO = get_custom_file(
|
CUSTOM_LOGO = get_custom_file(
|
||||||
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
||||||
|
@ -584,12 +584,9 @@ class FormatTest(TestCase):
|
|||||||
class TestHelpers(TestCase):
|
class TestHelpers(TestCase):
|
||||||
"""Tests for InvenTree helper functions."""
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|
||||||
@override_settings(SITE_URL=None)
|
|
||||||
def test_absolute_url(self):
|
def test_absolute_url(self):
|
||||||
"""Test helper function for generating an absolute URL."""
|
"""Test helper function for generating an absolute URL."""
|
||||||
base = 'https://demo.inventree.org:12345'
|
base = InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||||
|
|
||||||
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
|
|
||||||
|
|
||||||
tests = {
|
tests = {
|
||||||
'': base,
|
'': base,
|
||||||
|
@ -5,7 +5,11 @@ from django.apps import AppConfig
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
from common.settings import get_global_setting, set_global_setting
|
from common.settings import (
|
||||||
|
get_global_setting,
|
||||||
|
global_setting_overrides,
|
||||||
|
set_global_setting,
|
||||||
|
)
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@ -24,6 +28,7 @@ class CommonConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.clear_restart_flag()
|
self.clear_restart_flag()
|
||||||
|
self.override_global_settings()
|
||||||
|
|
||||||
def clear_restart_flag(self):
|
def clear_restart_flag(self):
|
||||||
"""Clear the SERVER_RESTART_REQUIRED setting."""
|
"""Clear the SERVER_RESTART_REQUIRED setting."""
|
||||||
@ -37,3 +42,24 @@ class CommonConfig(AppConfig):
|
|||||||
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
|
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def override_global_settings(self):
|
||||||
|
"""Update global settings based on environment variables."""
|
||||||
|
overrides = global_setting_overrides()
|
||||||
|
|
||||||
|
if not overrides:
|
||||||
|
return
|
||||||
|
|
||||||
|
for key, value in overrides.items():
|
||||||
|
try:
|
||||||
|
current_value = get_global_setting(key, create=False)
|
||||||
|
|
||||||
|
if current_value != value:
|
||||||
|
logger.info(
|
||||||
|
'Overriding global setting: %s = %s', value, current_value
|
||||||
|
)
|
||||||
|
set_global_setting(key, value, None, create=True)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Failed to override global setting %s -> %s', key, value)
|
||||||
|
continue
|
||||||
|
@ -46,6 +46,7 @@ import InvenTree.models
|
|||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
import users.models
|
import users.models
|
||||||
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
||||||
|
from common.settings import global_setting_overrides
|
||||||
from generic.enums import StringEnum
|
from generic.enums import StringEnum
|
||||||
from generic.states import ColorEnum
|
from generic.states import ColorEnum
|
||||||
from generic.states.custom import state_color_mappings
|
from generic.states.custom import state_color_mappings
|
||||||
@ -1157,11 +1158,42 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
|
|
||||||
If so, set the "SERVER_RESTART_REQUIRED" setting to True
|
If so, set the "SERVER_RESTART_REQUIRED" setting to True
|
||||||
"""
|
"""
|
||||||
|
overrides = global_setting_overrides()
|
||||||
|
|
||||||
|
# If an override is specified for this setting, use that value
|
||||||
|
if self.key in overrides:
|
||||||
|
self.value = overrides[self.key]
|
||||||
|
|
||||||
super().save()
|
super().save()
|
||||||
|
|
||||||
if self.requires_restart() and not InvenTree.ready.isImportingData():
|
if self.requires_restart() and not InvenTree.ready.isImportingData():
|
||||||
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
|
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_setting_default(cls, key, **kwargs):
|
||||||
|
"""Return the default value a particular setting."""
|
||||||
|
overrides = global_setting_overrides()
|
||||||
|
|
||||||
|
if key in overrides:
|
||||||
|
# If an override is specified for this setting, use that value
|
||||||
|
return overrides[key]
|
||||||
|
|
||||||
|
return super().get_setting_default(key, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_setting(cls, key, backup_value=None, **kwargs):
|
||||||
|
"""Get the value of a particular setting.
|
||||||
|
|
||||||
|
If it does not exist, return the backup value (default = None)
|
||||||
|
"""
|
||||||
|
overrides = global_setting_overrides()
|
||||||
|
|
||||||
|
if key in overrides:
|
||||||
|
# If an override is specified for this setting, use that value
|
||||||
|
return overrides[key]
|
||||||
|
|
||||||
|
return super().get_setting(key, backup_value=backup_value, **kwargs)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Dict of all global settings values:
|
Dict of all global settings values:
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from taggit.serializers import TagListSerializerField
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
import common.models as common_models
|
import common.models as common_models
|
||||||
|
import common.settings
|
||||||
import common.validators
|
import common.validators
|
||||||
import generic.states.custom
|
import generic.states.custom
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
@ -123,8 +124,28 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
|||||||
'model_name',
|
'model_name',
|
||||||
'api_url',
|
'api_url',
|
||||||
'typ',
|
'typ',
|
||||||
|
'read_only',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
read_only = serializers.SerializerMethodField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_(
|
||||||
|
'Indicates if the setting is overridden by an environment variable'
|
||||||
|
),
|
||||||
|
label=_('Override'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_read_only(self, obj) -> bool:
|
||||||
|
"""Return True if the setting 'read_only' (cannot be edited).
|
||||||
|
|
||||||
|
A setting may be "read-only" if:
|
||||||
|
|
||||||
|
- It is overridden by an environment variable.
|
||||||
|
"""
|
||||||
|
overrides = common.settings.global_setting_overrides()
|
||||||
|
|
||||||
|
return obj.key in overrides
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsSerializer(SettingsSerializer):
|
class UserSettingsSerializer(SettingsSerializer):
|
||||||
"""Serializer for the InvenTreeUserSetting model."""
|
"""Serializer for the InvenTreeUserSetting model."""
|
||||||
|
@ -3,6 +3,19 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
|
|
||||||
|
def global_setting_overrides() -> dict:
|
||||||
|
"""Return a dictionary of global settings overrides.
|
||||||
|
|
||||||
|
These values are set via environment variables or configuration file.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
if hasattr(settings, 'GLOBAL_SETTINGS_OVERRIDES'):
|
||||||
|
return settings.GLOBAL_SETTINGS_OVERRIDES or {}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_global_setting(key, backup_value=None, enviroment_key=None, **kwargs):
|
def get_global_setting(key, backup_value=None, enviroment_key=None, **kwargs):
|
||||||
"""Return the value of a global setting using the provided key."""
|
"""Return the value of a global setting using the provided key."""
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
@ -451,6 +451,15 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
f'Non-boolean default value specified for {key}'
|
f'Non-boolean default value specified for {key}'
|
||||||
) # pragma: no cover
|
) # pragma: no cover
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
GLOBAL_SETTINGS_OVERRIDES={'INVENTREE_INSTANCE': 'Overridden Instance Name'}
|
||||||
|
)
|
||||||
|
def test_override(self):
|
||||||
|
"""Test override of global settings."""
|
||||||
|
self.assertEqual(
|
||||||
|
get_global_setting('INVENTREE_INSTANCE'), 'Overridden Instance Name'
|
||||||
|
)
|
||||||
|
|
||||||
def test_global_setting_caching(self):
|
def test_global_setting_caching(self):
|
||||||
"""Test caching operations for the global settings class."""
|
"""Test caching operations for the global settings class."""
|
||||||
key = 'PART_NAME_FORMAT'
|
key = 'PART_NAME_FORMAT'
|
||||||
|
@ -211,3 +211,8 @@ ldap:
|
|||||||
# hide_password_reset: true
|
# hide_password_reset: true
|
||||||
# logo: img/custom_logo.png
|
# logo: img/custom_logo.png
|
||||||
# splash: img/custom_splash.jpg
|
# splash: img/custom_splash.jpg
|
||||||
|
|
||||||
|
# Override global settings
|
||||||
|
#global_settings:
|
||||||
|
# INVENTREE_DEFAULT_CURRENCY: 'CNY'
|
||||||
|
# INVENTREE_RESTRICT_ABOUT: true
|
||||||
|
@ -33,6 +33,7 @@ export interface Setting {
|
|||||||
plugin?: string;
|
plugin?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
read_only?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingChoice {
|
export interface SettingChoice {
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
useMantineColorScheme
|
useMantineColorScheme
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
@ -51,6 +51,23 @@ function SettingValue({
|
|||||||
|
|
||||||
const [modelInstance, setModelInstance] = useState<any>(null);
|
const [modelInstance, setModelInstance] = useState<any>(null);
|
||||||
|
|
||||||
|
// Launch the edit dialog for this setting
|
||||||
|
const editSetting = useCallback(() => {
|
||||||
|
if (!setting.read_only) {
|
||||||
|
onEdit(setting);
|
||||||
|
}
|
||||||
|
}, [setting, onEdit]);
|
||||||
|
|
||||||
|
// Toggle the setting value (if it is a boolean)
|
||||||
|
const toggleSetting = useCallback(
|
||||||
|
(event: any) => {
|
||||||
|
if (!setting.read_only) {
|
||||||
|
onToggle(setting, event.currentTarget.checked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setting, onToggle]
|
||||||
|
);
|
||||||
|
|
||||||
// Does this setting map to an internal database model?
|
// Does this setting map to an internal database model?
|
||||||
const modelType: ModelType | null = useMemo(() => {
|
const modelType: ModelType | null = useMemo(() => {
|
||||||
if (setting.model_name) {
|
if (setting.model_name) {
|
||||||
@ -89,7 +106,8 @@ function SettingValue({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={`edit-setting-${setting.key}`}
|
aria-label={`edit-setting-${setting.key}`}
|
||||||
variant='subtle'
|
variant='subtle'
|
||||||
onClick={() => onEdit(setting)}
|
disabled={setting.read_only}
|
||||||
|
onClick={editSetting}
|
||||||
>
|
>
|
||||||
<IconEdit />
|
<IconEdit />
|
||||||
</Button>
|
</Button>
|
||||||
@ -104,8 +122,9 @@ function SettingValue({
|
|||||||
size='sm'
|
size='sm'
|
||||||
radius='lg'
|
radius='lg'
|
||||||
aria-label={`toggle-setting-${setting.key}`}
|
aria-label={`toggle-setting-${setting.key}`}
|
||||||
|
disabled={setting.read_only}
|
||||||
checked={setting.value.toLowerCase() == 'true'}
|
checked={setting.value.toLowerCase() == 'true'}
|
||||||
onChange={(event) => onToggle(setting, event.currentTarget.checked)}
|
onChange={toggleSetting}
|
||||||
style={{
|
style={{
|
||||||
paddingRight: '20px'
|
paddingRight: '20px'
|
||||||
}}
|
}}
|
||||||
@ -118,7 +137,8 @@ function SettingValue({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={`edit-setting-${setting.key}`}
|
aria-label={`edit-setting-${setting.key}`}
|
||||||
variant='subtle'
|
variant='subtle'
|
||||||
onClick={() => onEdit(setting)}
|
disabled={setting.read_only}
|
||||||
|
onClick={editSetting}
|
||||||
>
|
>
|
||||||
{valueText}
|
{valueText}
|
||||||
</Button>
|
</Button>
|
||||||
@ -127,7 +147,8 @@ function SettingValue({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={`edit-setting-${setting.key}`}
|
aria-label={`edit-setting-${setting.key}`}
|
||||||
variant='subtle'
|
variant='subtle'
|
||||||
onClick={() => onEdit(setting)}
|
disabled={setting.read_only}
|
||||||
|
onClick={editSetting}
|
||||||
>
|
>
|
||||||
<IconEdit />
|
<IconEdit />
|
||||||
</Button>
|
</Button>
|
||||||
|
Reference in New Issue
Block a user