Merge pull request #2981 from SchrodingersGat/model-setting-reference

Add settings which reference *model instances* allowing better lookup of models
This commit is contained in:
Oliver 2022-05-12 17:59:56 +10:00 committed by GitHub
commit c2dd8afea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 182 additions and 116 deletions

View File

@ -17,15 +17,16 @@ import base64
from secrets import compare_digest from secrets import compare_digest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.apps import apps
from django.db import models, transaction 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.auth.models import User, Group
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.utils import IntegrityError, OperationalError from django.contrib.humanize.templatetags.humanize import naturaltime
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.contrib.humanize.templatetags.humanize import naturaltime
from djmoney.settings import CURRENCY_CHOICES from djmoney.settings import CURRENCY_CHOICES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
@ -136,6 +137,19 @@ class BaseInvenTreeSetting(models.Model):
return settings 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 @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
""" """
@ -319,11 +333,11 @@ class BaseInvenTreeSetting(models.Model):
value = setting.value value = setting.value
# Cast to boolean if necessary # Cast to boolean if necessary
if setting.is_bool(**kwargs): if setting.is_bool():
value = InvenTree.helpers.str2bool(value) value = InvenTree.helpers.str2bool(value)
# Cast to integer if necessary # Cast to integer if necessary
if setting.is_int(**kwargs): if setting.is_int():
try: try:
value = int(value) value = int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
@ -390,19 +404,19 @@ class BaseInvenTreeSetting(models.Model):
@property @property
def name(self): def name(self):
return self.__class__.get_setting_name(self.key) return self.__class__.get_setting_name(self.key, **self.get_kwargs())
@property @property
def default_value(self): def default_value(self):
return self.__class__.get_setting_default(self.key) return self.__class__.get_setting_default(self.key, **self.get_kwargs())
@property @property
def description(self): def description(self):
return self.__class__.get_setting_description(self.key) return self.__class__.get_setting_description(self.key, **self.get_kwargs())
@property @property
def units(self): 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): def clean(self, **kwargs):
""" """
@ -512,12 +526,12 @@ class BaseInvenTreeSetting(models.Model):
except self.DoesNotExist: except self.DoesNotExist:
pass pass
def choices(self, **kwargs): def choices(self):
""" """
Return the available choices for this setting (or None if no choices are defined) 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): def valid_options(self):
""" """
@ -531,14 +545,14 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices] return [opt[0] for opt in choices]
def is_choice(self, **kwargs): def is_choice(self):
""" """
Check if this setting is a "choice" field 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, Render this setting as the "display" value of a choice field,
e.g. if the choices are: e.g. if the choices are:
@ -547,7 +561,7 @@ class BaseInvenTreeSetting(models.Model):
then display 'A4 paper' 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: if not choices:
return self.value return self.value
@ -558,12 +572,80 @@ class BaseInvenTreeSetting(models.Model):
return self.value 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 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 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) return self.__class__.validator_is_bool(validator)
@ -576,17 +658,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
def setting_type(self, **kwargs): def setting_type(self):
""" """
Return the field type identifier for this setting object Return the field type identifier for this setting object
""" """
if self.is_bool(**kwargs): if self.is_bool():
return 'boolean' return 'boolean'
elif self.is_int(**kwargs): elif self.is_int():
return 'integer' return 'integer'
elif self.is_model():
return 'related field'
else: else:
return 'string' return 'string'
@ -603,12 +688,12 @@ class BaseInvenTreeSetting(models.Model):
return False return False
def is_int(self, **kwargs): def is_int(self,):
""" """
Check if the setting is required to be an integer value: 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) return self.__class__.validator_is_int(validator)
@ -651,88 +736,7 @@ class BaseInvenTreeSetting(models.Model):
@property @property
def protected(self): def protected(self):
return self.__class__.is_protected(self.key) return self.__class__.is_protected(self.key, **self.get_kwargs())
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())
def settings_group_options(): def settings_group_options():
@ -1558,6 +1562,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
return self.__class__.get_setting(self.key, user=self.user) 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): class PriceBreak(models.Model):
""" """

View File

@ -28,6 +28,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
choices = serializers.SerializerMethodField() choices = serializers.SerializerMethodField()
model_name = serializers.CharField(read_only=True)
api_url = serializers.CharField(read_only=True)
def get_choices(self, obj): def get_choices(self, obj):
""" """
Returns the choices available for a given item Returns the choices available for a given item
@ -75,6 +79,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
'description', 'description',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
@ -96,6 +102,8 @@ class UserSettingsSerializer(SettingsSerializer):
'user', 'user',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
@ -124,6 +132,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'description', 'description',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
# set Meta class # set Meta class

View File

@ -102,7 +102,7 @@ class PluginConfig(models.Model):
return ret return ret
class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): class PluginSetting(common.models.BaseInvenTreeSetting):
""" """
This model represents settings for individual plugins This model represents settings for individual plugins
""" """
@ -112,7 +112,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
('plugin', 'key'), ('plugin', 'key'),
] ]
REFERENCE_NAME = 'plugin' plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
@ -142,16 +148,18 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
return super().get_setting_definition(key, **kwargs) return super().get_setting_definition(key, **kwargs)
plugin = models.ForeignKey( def get_kwargs(self):
PluginConfig, """
related_name='settings', Explicit kwargs required to uniquely identify a particular setting object,
null=False, in addition to the 'key' parameter
verbose_name=_('Plugin'), """
on_delete=models.CASCADE,
) 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 This model represents notification settings for a user
""" """
@ -161,8 +169,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
('method', 'user', 'key'), ('method', 'user', 'key'),
] ]
REFERENCE_NAME = 'method'
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
from common.notifications import storage from common.notifications import storage
@ -171,6 +177,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
return super().get_setting_definition(key, **kwargs) 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( method = models.CharField(
max_length=255, max_length=255,
verbose_name=_('Method'), verbose_name=_('Method'),

View File

@ -65,6 +65,16 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
], ],
'default': 'A', 'default': 'A',
}, },
'SELECT_COMPANY': {
'name': 'Company',
'description': 'Select a company object from the database',
'model': 'company.company',
},
'SELECT_PART': {
'name': 'Part',
'description': 'Select a part object from the database',
'model': 'part.part',
},
} }
NAVIGATION = [ NAVIGATION = [

View File

@ -71,9 +71,24 @@ function editSetting(key, options={}) {
help_text: response.description, help_text: response.description,
type: response.type, type: response.type,
choices: response.choices, 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, { constructChangeForm(fields, {
url: url, url: url,
method: 'PATCH', method: 'PATCH',