[FR] Simplify / optimize setting loading (#4152)

* [FR] Simplify / optimize setting loading
Cache config.yaml data on load and use cached  for get_settings
Fixes #4149

* move the cache setting to config

* add docstring

* spell fix

* Add lookup where settings come from
Fixes #3982

* Fix spelling
This commit is contained in:
Matthias Mair 2023-01-07 13:24:20 +01:00 committed by GitHub
parent 0e96654b6a
commit 82bdd7780d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 112 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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/<str:key>/', ConfigDetail.as_view(), name='api-config-detail'),
]

View File

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

View File

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