2
0
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:
Oliver
2025-06-07 20:08:34 +10:00
committed by GitHub
parent 12677ccf22
commit 4b71130ebe
14 changed files with 178 additions and 23 deletions

View File

@ -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.

View File

@ -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* |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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."""

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -33,6 +33,7 @@ export interface Setting {
plugin?: string;
method?: string;
required?: boolean;
read_only?: boolean;
}
export interface SettingChoice {

View File

@ -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>