From e112d555d403be244871c04bd5fd9b9d0288fe22 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 16:45:27 +1000 Subject: [PATCH 1/3] Simplify the various settings objects, to improve retrieval of 'parameters' from the base class - Remove the GenericReferencedSettingsClass mixin - Each subclass defines a very simple get_kwargs() method - Now, at object level *and* class level we can perform lookup of settings and actually get proper data back - Adds "model" option to setting (precursor of things to come) --- InvenTree/common/models.py | 163 +++++++----------- InvenTree/common/serializers.py | 5 + InvenTree/plugin/models.py | 43 +++-- .../plugin/samples/integration/sample.py | 5 + .../templates/InvenTree/settings/setting.html | 3 + 5 files changed, 105 insertions(+), 114 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 11157763cb..37a6289d75 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -136,6 +136,19 @@ class BaseInvenTreeSetting(models.Model): return settings + def get_kwargs(self): + """ + Construct kwargs for doing class-based settings lookup, + depending on *which* class we are. + + This is necessary to abtract the settings object + from the implementing class (e.g plugins) + + Subclasses should override this function to ensure the kwargs are correctly set. + """ + + return {} + @classmethod def get_setting_definition(cls, key, **kwargs): """ @@ -319,11 +332,11 @@ class BaseInvenTreeSetting(models.Model): value = setting.value # Cast to boolean if necessary - if setting.is_bool(**kwargs): + if setting.is_bool(): value = InvenTree.helpers.str2bool(value) # Cast to integer if necessary - if setting.is_int(**kwargs): + if setting.is_int(): try: value = int(value) except (ValueError, TypeError): @@ -390,19 +403,19 @@ class BaseInvenTreeSetting(models.Model): @property def name(self): - return self.__class__.get_setting_name(self.key) + return self.__class__.get_setting_name(self.key, **self.get_kwargs()) @property def default_value(self): - return self.__class__.get_setting_default(self.key) + return self.__class__.get_setting_default(self.key, **self.get_kwargs()) @property def description(self): - return self.__class__.get_setting_description(self.key) + return self.__class__.get_setting_description(self.key, **self.get_kwargs()) @property def units(self): - return self.__class__.get_setting_units(self.key) + return self.__class__.get_setting_units(self.key, **self.get_kwargs()) def clean(self, **kwargs): """ @@ -512,12 +525,12 @@ class BaseInvenTreeSetting(models.Model): except self.DoesNotExist: pass - def choices(self, **kwargs): + def choices(self): """ Return the available choices for this setting (or None if no choices are defined) """ - return self.__class__.get_setting_choices(self.key, **kwargs) + return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) def valid_options(self): """ @@ -531,14 +544,14 @@ class BaseInvenTreeSetting(models.Model): return [opt[0] for opt in choices] - def is_choice(self, **kwargs): + def is_choice(self): """ Check if this setting is a "choice" field """ - return self.__class__.get_setting_choices(self.key, **kwargs) is not None + return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None - def as_choice(self, **kwargs): + def as_choice(self): """ Render this setting as the "display" value of a choice field, e.g. if the choices are: @@ -547,7 +560,7 @@ class BaseInvenTreeSetting(models.Model): then display 'A4 paper' """ - choices = self.get_setting_choices(self.key, **kwargs) + choices = self.get_setting_choices(self.key, **self.get_kwargs()) if not choices: return self.value @@ -558,12 +571,28 @@ class BaseInvenTreeSetting(models.Model): return self.value - def is_bool(self, **kwargs): + def is_model(self): + """ + Check if this setting references a model instance in the database + """ + + return self.model_name() is not None + + def model_name(self): + """ + Return the model name associated with this setting + """ + + setting = self.get_setting_definition(self.key, **self.get_kwargs()) + + return setting.get('model', None) + + def is_bool(self): """ Check if this setting is required to be a boolean value """ - validator = self.__class__.get_setting_validator(self.key, **kwargs) + validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs()) return self.__class__.validator_is_bool(validator) @@ -576,17 +605,20 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) - def setting_type(self, **kwargs): + def setting_type(self): """ Return the field type identifier for this setting object """ - if self.is_bool(**kwargs): + if self.is_bool(): return 'boolean' - elif self.is_int(**kwargs): + elif self.is_int(): return 'integer' + elif self.is_model(): + return 'model' + else: return 'string' @@ -603,12 +635,12 @@ class BaseInvenTreeSetting(models.Model): return False - def is_int(self, **kwargs): + def is_int(self,): """ Check if the setting is required to be an integer value: """ - validator = self.__class__.get_setting_validator(self.key, **kwargs) + validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs()) return self.__class__.validator_is_int(validator) @@ -651,88 +683,7 @@ class BaseInvenTreeSetting(models.Model): @property def protected(self): - return self.__class__.is_protected(self.key) - - -class GenericReferencedSettingClass: - """ - This mixin can be used to add reference keys to static properties - - Sample: - ```python - class SampleSetting(GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): - class Meta: - unique_together = [ - ('sample', 'key'), - ] - - REFERENCE_NAME = 'sample' - - @classmethod - def get_setting_definition(cls, key, **kwargs): - # mysampledict contains the dict with all settings for this SettingClass - this could also be a dynamic lookup - - kwargs['settings'] = mysampledict - return super().get_setting_definition(key, **kwargs) - - sample = models.charKey( # the name for this field is the additonal key and must be set in the Meta class an REFERENCE_NAME - max_length=256, - verbose_name=_('sample') - ) - ``` - """ - - REFERENCE_NAME = None - - def _get_reference(self): - """ - Returns dict that can be used as an argument for kwargs calls. - Helps to make overriden calls generic for simple reuse. - - Usage: - ```python - some_random_function(argument0, kwarg1=value1, **self._get_reference()) - ``` - """ - return { - self.REFERENCE_NAME: getattr(self, self.REFERENCE_NAME) - } - - """ - We override the following class methods, - so that we can pass the modified key instance as an additional argument - """ - - def clean(self, **kwargs): - - kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME) - - super().clean(**kwargs) - - def is_bool(self, **kwargs): - - kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME) - - return super().is_bool(**kwargs) - - @property - def name(self): - return self.__class__.get_setting_name(self.key, **self._get_reference()) - - @property - def default_value(self): - return self.__class__.get_setting_default(self.key, **self._get_reference()) - - @property - def description(self): - return self.__class__.get_setting_description(self.key, **self._get_reference()) - - @property - def units(self): - return self.__class__.get_setting_units(self.key, **self._get_reference()) - - def choices(self): - return self.__class__.get_setting_choices(self.key, **self._get_reference()) + return self.__class__.is_protected(self.key, **self.get_kwargs()) def settings_group_options(): @@ -1558,6 +1509,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): return self.__class__.get_setting(self.key, user=self.user) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'user': self.user, + } + class PriceBreak(models.Model): """ diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 9d637c3e39..27fc15bca5 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -28,6 +28,8 @@ class SettingsSerializer(InvenTreeModelSerializer): choices = serializers.SerializerMethodField() + model_name = serializers.CharField(read_only=True) + def get_choices(self, obj): """ Returns the choices available for a given item @@ -75,6 +77,7 @@ class GlobalSettingsSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', ] @@ -96,6 +99,7 @@ class UserSettingsSerializer(SettingsSerializer): 'user', 'type', 'choices', + 'model_name', ] @@ -124,6 +128,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', ] # set Meta class diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 0624693abc..1620bed230 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -102,7 +102,7 @@ class PluginConfig(models.Model): return ret -class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): +class PluginSetting(common.models.BaseInvenTreeSetting): """ This model represents settings for individual plugins """ @@ -112,7 +112,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B ('plugin', 'key'), ] - REFERENCE_NAME = 'plugin' + plugin = models.ForeignKey( + PluginConfig, + related_name='settings', + null=False, + verbose_name=_('Plugin'), + on_delete=models.CASCADE, + ) @classmethod def get_setting_definition(cls, key, **kwargs): @@ -131,7 +137,7 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B if 'settings' not in kwargs: - plugin = kwargs.pop('plugin', None) + plugin = kwargs.pop('plugin') if plugin: @@ -142,16 +148,18 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B return super().get_setting_definition(key, **kwargs) - plugin = models.ForeignKey( - PluginConfig, - related_name='settings', - null=False, - verbose_name=_('Plugin'), - on_delete=models.CASCADE, - ) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'plugin': self.plugin, + } -class NotificationUserSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): +class NotificationUserSetting(common.models.BaseInvenTreeSetting): """ This model represents notification settings for a user """ @@ -161,8 +169,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo ('method', 'user', 'key'), ] - REFERENCE_NAME = 'method' - @classmethod def get_setting_definition(cls, key, **kwargs): from common.notifications import storage @@ -171,6 +177,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo return super().get_setting_definition(key, **kwargs) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'method': self.method, + 'user': self.user, + } + method = models.CharField( max_length=255, verbose_name=_('Method'), diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index 2df3bc116a..af99727ed6 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -65,6 +65,11 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi ], 'default': 'A', }, + 'SELECT_COMPANY': { + 'name': 'Company', + 'description': 'Select a company object from the database', + 'model': 'company.Company', + }, } NAVIGATION = [ diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 0bc099f8a2..55c323faec 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -22,6 +22,9 @@ {{ setting.description }} + {% if setting.model_name %} + Model name: {{ setting.model_name }} + {% endif %} {% if setting.is_bool %}
From a81ea01e8e250d8b47a884b7419603c0a07b1109 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 17:28:55 +1000 Subject: [PATCH 2/3] Model introspection - Find the class registered to the model (or log an error) - Pass the api_url through to the frontend --- InvenTree/common/models.py | 61 +++++++++++++++++-- InvenTree/common/serializers.py | 5 ++ InvenTree/plugin/models.py | 2 +- .../plugin/samples/integration/sample.py | 7 ++- .../templates/InvenTree/settings/setting.html | 3 - InvenTree/templates/js/dynamic/settings.js | 15 +++++ 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 37a6289d75..a13bbec071 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -17,15 +17,16 @@ import base64 from secrets import compare_digest from datetime import datetime, timedelta +from django.apps import apps from django.db import models, transaction +from django.db.utils import IntegrityError, OperationalError +from django.conf import settings from django.contrib.auth.models import User, Group from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db.utils import IntegrityError, OperationalError -from django.conf import settings +from django.contrib.humanize.templatetags.humanize import naturaltime from django.urls import reverse from django.utils.timezone import now -from django.contrib.humanize.templatetags.humanize import naturaltime from djmoney.settings import CURRENCY_CHOICES from djmoney.contrib.exchange.models import convert_money @@ -587,6 +588,58 @@ class BaseInvenTreeSetting(models.Model): return setting.get('model', None) + def model_class(self): + """ + Return the model class associated with this setting, if (and only if): + + - It has a defined 'model' parameter + - The 'model' parameter is of the form app.model + - The 'model' parameter has matches a known app model + """ + + model_name = self.model_name() + + if not model_name: + return None + + try: + (app, mdl) = model_name.strip().split('.') + except ValueError: + logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'") + return None + + app_models = apps.all_models.get(app, None) + + if app_models is None: + logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'") + return None + + model = app_models.get(mdl, None) + + if model is None: + logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'") + return None + + # Looks like we have found a model! + return model + + def api_url(self): + """ + Return the API url associated with the linked model, + if provided, and valid! + """ + + model_class = self.model_class() + + if model_class: + # If a valid class has been found, see if it has registered an API URL + try: + return model_class.get_api_url() + except: + pass + + return None + def is_bool(self): """ Check if this setting is required to be a boolean value @@ -617,7 +670,7 @@ class BaseInvenTreeSetting(models.Model): return 'integer' elif self.is_model(): - return 'model' + return 'related field' else: return 'string' diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 27fc15bca5..8dd0f5bcee 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -30,6 +30,8 @@ class SettingsSerializer(InvenTreeModelSerializer): model_name = serializers.CharField(read_only=True) + api_url = serializers.CharField(read_only=True) + def get_choices(self, obj): """ Returns the choices available for a given item @@ -78,6 +80,7 @@ class GlobalSettingsSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] @@ -100,6 +103,7 @@ class UserSettingsSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] @@ -129,6 +133,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] # set Meta class diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 1620bed230..18320cc34b 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -137,7 +137,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting): if 'settings' not in kwargs: - plugin = kwargs.pop('plugin') + plugin = kwargs.pop('plugin', None) if plugin: diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index af99727ed6..a3a26e7609 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -68,7 +68,12 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'SELECT_COMPANY': { 'name': 'Company', 'description': 'Select a company object from the database', - 'model': 'company.Company', + 'model': 'company.company', + }, + 'SELECT_PART': { + 'name': 'Part', + 'description': 'Select a part object from the database', + 'model': 'part.part', }, } diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 55c323faec..0bc099f8a2 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -22,9 +22,6 @@ {{ setting.description }} - {% if setting.model_name %} - Model name: {{ setting.model_name }} - {% endif %} {% if setting.is_bool %}
diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index 21eb9df5e2..5b52c2a015 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -71,9 +71,24 @@ function editSetting(key, options={}) { help_text: response.description, type: response.type, choices: response.choices, + value: response.value, } }; + // Foreign key lookup available! + if (response.type == 'related field') { + + if (response.model_name && response.api_url) { + fields.value.type = 'related field'; + fields.value.model = response.model_name.split('.').at(-1); + fields.value.api_url = response.api_url; + } else { + // Unknown / unsupported model type, default to 'text' field + fields.value.type = 'text'; + console.warn(`Unsupported model type: '${response.model_name}' for setting '${response.key}'`); + } + } + constructChangeForm(fields, { url: url, method: 'PATCH', From c4fa72e54c7ef0fa2651549a1f65a96fae9a7d52 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 17:30:52 +1000 Subject: [PATCH 3/3] PEP style fixes --- InvenTree/common/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index a13bbec071..2ec83c962c 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -144,7 +144,7 @@ class BaseInvenTreeSetting(models.Model): This is necessary to abtract the settings object from the implementing class (e.g plugins) - + Subclasses should override this function to ensure the kwargs are correctly set. """ @@ -601,7 +601,7 @@ class BaseInvenTreeSetting(models.Model): if not model_name: return None - + try: (app, mdl) = model_name.strip().split('.') except ValueError: @@ -637,7 +637,7 @@ class BaseInvenTreeSetting(models.Model): return model_class.get_api_url() except: pass - + return None def is_bool(self):