mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-13 18:45:40 +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
|
||||
|
||||
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_FILE | plugins_plugin_file | Location of plugin installation file | *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
|
||||
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."""
|
||||
|
||||
|
||||
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
|
||||
- Adds barcode information to SalesOrderShipment API endpoint
|
||||
|
||||
|
@ -17,7 +17,6 @@ from allauth.socialaccount.signals import social_account_updated
|
||||
import InvenTree.conversion
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree.config import get_setting
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@ -255,7 +254,6 @@ class InvenTreeConfig(AppConfig):
|
||||
def update_site_url(self):
|
||||
"""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 not InvenTree.ready.canAppAccessDatabase():
|
||||
@ -265,22 +263,16 @@ class InvenTreeConfig(AppConfig):
|
||||
return
|
||||
|
||||
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
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
site = Site.objects.get_current()
|
||||
site.domain = settings.SITE_URL
|
||||
site.save()
|
||||
|
||||
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
||||
if site and site.domain != settings.SITE_URL:
|
||||
site.domain = settings.SITE_URL
|
||||
site.save()
|
||||
logger.info('Updated current site URL to %s', settings.SITE_URL)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
@ -1397,6 +1397,22 @@ TESTING_TABLE_EVENTS = False
|
||||
# Flag to allow pricing recalculations during testing
|
||||
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
|
||||
CUSTOM_LOGO = get_custom_file(
|
||||
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
|
||||
|
@ -584,12 +584,9 @@ class FormatTest(TestCase):
|
||||
class TestHelpers(TestCase):
|
||||
"""Tests for InvenTree helper functions."""
|
||||
|
||||
@override_settings(SITE_URL=None)
|
||||
def test_absolute_url(self):
|
||||
"""Test helper function for generating an absolute URL."""
|
||||
base = 'https://demo.inventree.org:12345'
|
||||
|
||||
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
|
||||
base = InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||
|
||||
tests = {
|
||||
'': base,
|
||||
|
@ -5,7 +5,11 @@ from django.apps import AppConfig
|
||||
import structlog
|
||||
|
||||
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')
|
||||
|
||||
@ -24,6 +28,7 @@ class CommonConfig(AppConfig):
|
||||
return
|
||||
|
||||
self.clear_restart_flag()
|
||||
self.override_global_settings()
|
||||
|
||||
def clear_restart_flag(self):
|
||||
"""Clear the SERVER_RESTART_REQUIRED setting."""
|
||||
@ -37,3 +42,24 @@ class CommonConfig(AppConfig):
|
||||
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
|
||||
except Exception:
|
||||
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 users.models
|
||||
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
||||
from common.settings import global_setting_overrides
|
||||
from generic.enums import StringEnum
|
||||
from generic.states import ColorEnum
|
||||
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
|
||||
"""
|
||||
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()
|
||||
|
||||
if self.requires_restart() and not InvenTree.ready.isImportingData():
|
||||
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:
|
||||
|
||||
|
@ -15,6 +15,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import common.settings
|
||||
import common.validators
|
||||
import generic.states.custom
|
||||
from importer.registry import register_importer
|
||||
@ -123,8 +124,28 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
||||
'model_name',
|
||||
'api_url',
|
||||
'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):
|
||||
"""Serializer for the InvenTreeUserSetting model."""
|
||||
|
@ -3,6 +3,19 @@
|
||||
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):
|
||||
"""Return the value of a global setting using the provided key."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
@ -451,6 +451,15 @@ class SettingsTest(InvenTreeTestCase):
|
||||
f'Non-boolean default value specified for {key}'
|
||||
) # 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):
|
||||
"""Test caching operations for the global settings class."""
|
||||
key = 'PART_NAME_FORMAT'
|
||||
|
@ -211,3 +211,8 @@ ldap:
|
||||
# hide_password_reset: true
|
||||
# logo: img/custom_logo.png
|
||||
# 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;
|
||||
method?: string;
|
||||
required?: boolean;
|
||||
read_only?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingChoice {
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
useMantineColorScheme
|
||||
} from '@mantine/core';
|
||||
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 { ModelType } from '@lib/enums/ModelType';
|
||||
@ -51,6 +51,23 @@ function SettingValue({
|
||||
|
||||
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?
|
||||
const modelType: ModelType | null = useMemo(() => {
|
||||
if (setting.model_name) {
|
||||
@ -89,7 +106,8 @@ function SettingValue({
|
||||
<Button
|
||||
aria-label={`edit-setting-${setting.key}`}
|
||||
variant='subtle'
|
||||
onClick={() => onEdit(setting)}
|
||||
disabled={setting.read_only}
|
||||
onClick={editSetting}
|
||||
>
|
||||
<IconEdit />
|
||||
</Button>
|
||||
@ -104,8 +122,9 @@ function SettingValue({
|
||||
size='sm'
|
||||
radius='lg'
|
||||
aria-label={`toggle-setting-${setting.key}`}
|
||||
disabled={setting.read_only}
|
||||
checked={setting.value.toLowerCase() == 'true'}
|
||||
onChange={(event) => onToggle(setting, event.currentTarget.checked)}
|
||||
onChange={toggleSetting}
|
||||
style={{
|
||||
paddingRight: '20px'
|
||||
}}
|
||||
@ -118,7 +137,8 @@ function SettingValue({
|
||||
<Button
|
||||
aria-label={`edit-setting-${setting.key}`}
|
||||
variant='subtle'
|
||||
onClick={() => onEdit(setting)}
|
||||
disabled={setting.read_only}
|
||||
onClick={editSetting}
|
||||
>
|
||||
{valueText}
|
||||
</Button>
|
||||
@ -127,7 +147,8 @@ function SettingValue({
|
||||
<Button
|
||||
aria-label={`edit-setting-${setting.key}`}
|
||||
variant='subtle'
|
||||
onClick={() => onEdit(setting)}
|
||||
disabled={setting.read_only}
|
||||
onClick={editSetting}
|
||||
>
|
||||
<IconEdit />
|
||||
</Button>
|
||||
|
Reference in New Issue
Block a user