diff --git a/InvenTree/InvenTree/config.py b/InvenTree/InvenTree/config.py index 27722435f3..892e5751c4 100644 --- a/InvenTree/InvenTree/config.py +++ b/InvenTree/InvenTree/config.py @@ -1,5 +1,6 @@ """Helper functions for loading InvenTree configuration options.""" +import datetime import logging import os import random @@ -8,6 +9,8 @@ import string from pathlib import Path logger = logging.getLogger('inventree') +CONFIG_DATA = None +CONFIG_LOOKUPS = {} def is_true(x): @@ -56,8 +59,18 @@ def get_config_file(create=True) -> Path: return cfg_filename -def load_config_data() -> map: - """Load configuration data from the config file.""" +def load_config_data(set_cache: bool = False) -> map: + """Load configuration data from the config file. + + Arguments: + set_cache(bool): If True, the configuration data will be cached for future use after load. + """ + global CONFIG_DATA + + # use cache if populated + # skip cache if cache should be set + if CONFIG_DATA is not None and not set_cache: + return CONFIG_DATA import yaml @@ -66,6 +79,10 @@ def load_config_data() -> map: with open(cfg_file, 'r') as cfg: data = yaml.safe_load(cfg) + # Set the cache if requested + if set_cache: + CONFIG_DATA = data + return data @@ -82,22 +99,30 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None default_value: Value to return if first two options are not provided typecast: Function to use for typecasting the value """ - def try_typecasting(value): + def try_typecasting(value, source: str): """Attempt to typecast the value""" if typecast is not None: # Try to typecast the value try: - return typecast(value) + val = typecast(value) + set_metadata(source) + return val except Exception as error: logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}") + set_metadata(source) return value + def set_metadata(source: str): + """Set lookup metadata for the setting.""" + key = env_var or config_key + CONFIG_LOOKUPS[key] = {'env_var': env_var, 'config_key': config_key, 'source': source, 'accessed': datetime.datetime.now()} + # First, try to load from the environment variables if env_var is not None: val = os.getenv(env_var, None) if val is not None: - return try_typecasting(val) + return try_typecasting(val, 'env') # Next, try to load from configuration file if config_key is not None: @@ -116,10 +141,10 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None cfg_data = cfg_data[key] if result is not None: - return try_typecasting(result) + return try_typecasting(result, 'yaml') # Finally, return the default value - return try_typecasting(default_value) + return try_typecasting(default_value, 'default') def get_boolean_setting(env_var=None, config_key=None, default_value=False): diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 74d42b6da0..714ff99139 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -67,6 +67,14 @@ class RolePermission(permissions.BasePermission): return result +class IsSuperuser(permissions.IsAdminUser): + """Allows access only to superuser users.""" + + def has_permission(self, request, view): + """Check if the user is a superuser.""" + return bool(request.user and request.user.is_superuser) + + def auth_exempt(view_func): """Mark a view function as being exempt from auth requirements.""" def wrapped_view(*args, **kwargs): diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5cc4e7ed0c..b85c45a3b4 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -52,7 +52,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' BASE_DIR = config.get_base_dir() # Load configuration data -CONFIG = config.load_config_data() +CONFIG = config.load_config_data(set_cache=True) # Default action is to run the system in Debug mode # SECURITY WARNING: don't run with debug turned on in production! diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 50455d7601..d63e4022f4 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -13,7 +13,7 @@ from rest_framework.documentation import include_docs_urls from build.api import build_api_urls from build.urls import build_urls -from common.api import common_api_urls, settings_api_urls +from common.api import admin_api_urls, common_api_urls, settings_api_urls from common.urls import common_urls from company.api import company_api_urls from company.urls import (company_urls, manufacturer_part_urls, @@ -53,6 +53,7 @@ apipatterns = [ re_path(r'^label/', include(label_api_urls)), re_path(r'^report/', include(report_api_urls)), re_path(r'^user/', include(user_urls)), + re_path(r'^admin/', include(admin_api_urls)), # Plugin endpoints path('', include(plugin_api_urls)), diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index e87194446e..d17108d3a0 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -18,9 +18,11 @@ from rest_framework.views import APIView import common.models import common.serializers from InvenTree.api import BulkDeleteMixin +from InvenTree.config import CONFIG_LOOKUPS from InvenTree.helpers import inheritors from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) +from InvenTree.permissions import IsSuperuser from plugin.models import NotificationUserSetting from plugin.serializers import NotificationUserSettingSerializer @@ -360,6 +362,29 @@ class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI): """Detail view for an individual news feed object.""" +class ConfigList(ListAPI): + """List view for all accessed configurations.""" + + queryset = CONFIG_LOOKUPS + serializer_class = common.serializers.ConfigSerializer + permission_classes = [IsSuperuser, ] + + +class ConfigDetail(RetrieveAPI): + """Detail view for an individual configuration.""" + + serializer_class = common.serializers.ConfigSerializer + permission_classes = [IsSuperuser, ] + + def get_object(self): + """Attempt to find a config object with the provided key.""" + key = self.kwargs['key'] + value = CONFIG_LOOKUPS.get(key, None) + if not value: + raise NotFound() + return {key: value} + + settings_api_urls = [ # User settings re_path(r'^user/', include([ @@ -415,3 +440,9 @@ common_api_urls = [ ])), ] + +admin_api_urls = [ + # Admin + path('config/', ConfigList.as_view(), name='api-config-list'), + path('config//', ConfigDetail.as_view(), name='api-config-detail'), +] diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index b39d19cfc0..c84d478548 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -222,3 +222,16 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer): 'summary', 'read', ] + + +class ConfigSerializer(serializers.Serializer): + """Serializer for the InvenTree configuration. + + This is a read-only serializer. + """ + + def to_representation(self, instance): + """Return the configuration data as a dictionary.""" + if not isinstance(instance, str): + instance = list(instance.keys())[0] + return {'key': instance, **self.instance[instance]} diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index b305073045..2cf795de24 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -827,7 +827,7 @@ class NotificationTest(InvenTreeAPITestCase): self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3) -class LoadingTest(TestCase): +class CommonTest(InvenTreeAPITestCase): """Tests for the common config.""" def test_restart_flag(self): @@ -844,6 +844,30 @@ class LoadingTest(TestCase): # now it should be false again self.assertFalse(common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED')) + def test_config_api(self): + """Test config URLs.""" + # Not superuser + self.get(reverse('api-config-list'), expected_code=403) + + # Turn into superuser + self.user.is_superuser = True + self.user.save() + + # Successfull checks + data = [ + self.get(reverse('api-config-list'), expected_code=200).data[0], # list endpoint + self.get(reverse('api-config-detail', kwargs={'key': 'INVENTREE_DEBUG'}), expected_code=200).data, # detail endpoint + ] + + for item in data: + self.assertEqual(item['key'], 'INVENTREE_DEBUG') + self.assertEqual(item['env_var'], 'INVENTREE_DEBUG') + self.assertEqual(item['config_key'], 'debug') + + # Turn into normal user again + self.user.is_superuser = False + self.user.save() + class ColorThemeTest(TestCase): """Tests for ColorTheme."""