From 4b71130ebed3d634dd5da05b0037f96d036a9a66 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 7 Jun 2025 20:08:34 +1000 Subject: [PATCH] [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 --- docs/docs/settings/global.md | 8 +++++ docs/docs/start/config.md | 10 ++++++ .../InvenTree/InvenTree/api_version.py | 6 +++- src/backend/InvenTree/InvenTree/apps.py | 16 +++------- src/backend/InvenTree/InvenTree/settings.py | 16 ++++++++++ src/backend/InvenTree/InvenTree/tests.py | 5 +-- src/backend/InvenTree/common/apps.py | 28 +++++++++++++++- src/backend/InvenTree/common/models.py | 32 +++++++++++++++++++ src/backend/InvenTree/common/serializers.py | 21 ++++++++++++ src/backend/InvenTree/common/settings.py | 13 ++++++++ src/backend/InvenTree/common/tests.py | 9 ++++++ src/backend/InvenTree/config_template.yaml | 5 +++ src/frontend/lib/types/Settings.tsx | 1 + .../src/components/settings/SettingItem.tsx | 31 +++++++++++++++--- 14 files changed, 178 insertions(+), 23 deletions(-) diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 9cf7c2a346..9b58e2c48b 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -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. diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 0feb8a647f..5a1395850e 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -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* | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b8446ff0b5..9aceca3b8c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index ea13d092cc..6fae28f69f 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 7d4738c2cf..508cf84278 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 5f3275d14d..814ecbf4de 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -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, diff --git a/src/backend/InvenTree/common/apps.py b/src/backend/InvenTree/common/apps.py index d07757cb3c..9a6e9856f6 100644 --- a/src/backend/InvenTree/common/apps.py +++ b/src/backend/InvenTree/common/apps.py @@ -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 diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index d26006b44e..4eaa8094cc 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -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: diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 1931e35d72..d4265fc19b 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -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.""" diff --git a/src/backend/InvenTree/common/settings.py b/src/backend/InvenTree/common/settings.py index e1bc11f518..44c69391b8 100644 --- a/src/backend/InvenTree/common/settings.py +++ b/src/backend/InvenTree/common/settings.py @@ -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 diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index dd2d89a0a9..1de3db33af 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -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' diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index b67426b086..eecef083d6 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -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 diff --git a/src/frontend/lib/types/Settings.tsx b/src/frontend/lib/types/Settings.tsx index 71acf685f2..25643d5d4b 100644 --- a/src/frontend/lib/types/Settings.tsx +++ b/src/frontend/lib/types/Settings.tsx @@ -33,6 +33,7 @@ export interface Setting { plugin?: string; method?: string; required?: boolean; + read_only?: boolean; } export interface SettingChoice { diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 71fa6f0ead..c5f14c0b5b 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -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(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({ @@ -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({ @@ -127,7 +147,8 @@ function SettingValue({