mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
This commit is contained in:
@ -38,7 +38,7 @@ The type of data this field holds. This must be one of the following:
|
||||
| Object | A single NetBox object of the type defined by `object_type` |
|
||||
| Multiple object | One or more NetBox objects of the type defined by `object_type` |
|
||||
|
||||
### Object Type
|
||||
### Related Object Type
|
||||
|
||||
For object and multiple-object fields only. Designates the type of NetBox object being referenced.
|
||||
|
||||
|
@ -57,10 +57,10 @@ class CustomFieldsDataField(Field):
|
||||
for cf in self._get_custom_fields():
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class())
|
||||
serializer = get_serializer_for_model(cf.related_object_type.model_class())
|
||||
value = serializer(value, nested=True, context=self.parent.context).data
|
||||
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class())
|
||||
serializer = get_serializer_for_model(cf.related_object_type.model_class())
|
||||
value = serializer(value, nested=True, many=True, context=self.parent.context).data
|
||||
data[cf.name] = value
|
||||
|
||||
@ -79,7 +79,7 @@ class CustomFieldsDataField(Field):
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
serializer_class = get_serializer_for_model(cf.object_type.model_class())
|
||||
serializer_class = get_serializer_for_model(cf.related_object_type.model_class())
|
||||
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context)
|
||||
if serializer.is_valid():
|
||||
|
@ -44,7 +44,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
many=True
|
||||
)
|
||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||
object_type = ContentTypeField(
|
||||
related_object_type = ContentTypeField(
|
||||
queryset=ObjectType.objects.all(),
|
||||
required=False,
|
||||
allow_null=True
|
||||
@ -62,10 +62,10 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'object_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||
'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
|
||||
'created', 'last_updated',
|
||||
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label',
|
||||
'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
|
||||
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choice_set', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
@ -132,6 +132,10 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
object_type = ContentTypeFilter(
|
||||
field_name='object_types'
|
||||
)
|
||||
related_object_type_id = MultiValueNumberFilter(
|
||||
field_name='related_object_type__id'
|
||||
)
|
||||
related_object_type = ContentTypeFilter()
|
||||
choice_set_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=CustomFieldChoiceSet.objects.all()
|
||||
)
|
||||
|
@ -40,7 +40,7 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
choices=CustomFieldTypeChoices,
|
||||
help_text=_('Field data type (e.g. text, integer, etc.)')
|
||||
)
|
||||
object_type = CSVContentTypeField(
|
||||
related_object_type = CSVContentTypeField(
|
||||
label=_('Object type'),
|
||||
queryset=ObjectType.objects.public(),
|
||||
required=False,
|
||||
@ -69,7 +69,7 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'object_types', 'object_type', 'required', 'description',
|
||||
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
|
||||
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
||||
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
)
|
||||
|
@ -38,14 +38,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
(_('Attributes'), (
|
||||
'type', 'object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
|
||||
'is_cloneable',
|
||||
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible',
|
||||
'ui_editable', 'is_cloneable',
|
||||
)),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
related_object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('custom_fields'),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
label=_('Related object type')
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=CustomFieldTypeChoices,
|
||||
|
@ -42,8 +42,8 @@ class CustomFieldForm(forms.ModelForm):
|
||||
label=_('Object types'),
|
||||
queryset=ObjectType.objects.with_feature('custom_fields')
|
||||
)
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object type'),
|
||||
related_object_type = ContentTypeChoiceField(
|
||||
label=_('Related object type'),
|
||||
queryset=ObjectType.objects.public(),
|
||||
required=False,
|
||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||
@ -55,7 +55,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
fieldsets = (
|
||||
(_('Custom Field'), (
|
||||
'object_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
||||
'object_types', 'name', 'label', 'group_name', 'type', 'related_object_type', 'required', 'description',
|
||||
)),
|
||||
(_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')),
|
||||
(_('Values'), ('default', 'choice_set')),
|
||||
|
@ -0,0 +1,16 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0112_tag_update_object_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='customfield',
|
||||
old_name='object_type',
|
||||
new_name='related_object_type',
|
||||
),
|
||||
]
|
@ -78,7 +78,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
default=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
help_text=_('The type of data this custom field holds')
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
related_object_type = models.ForeignKey(
|
||||
to='core.ObjectType',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
@ -209,7 +209,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
objects = CustomFieldManager()
|
||||
|
||||
clone_fields = (
|
||||
'object_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
)
|
||||
@ -344,11 +344,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Object fields must define an object_type; other fields must not
|
||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||
if not self.object_type:
|
||||
if not self.related_object_type:
|
||||
raise ValidationError({
|
||||
'object_type': _("Object fields must define an object type.")
|
||||
})
|
||||
elif self.object_type:
|
||||
elif self.related_object_type:
|
||||
raise ValidationError({
|
||||
'object_type': _(
|
||||
"{type} fields may not define an object type.")
|
||||
@ -388,10 +388,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
except ValueError:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
model = self.related_object_type.model_class()
|
||||
return model.objects.filter(pk=value).first()
|
||||
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
model = self.related_object_type.model_class()
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
@ -488,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
model = self.related_object_type.model_class()
|
||||
field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
@ -498,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Multiple objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
model = self.related_object_type.model_class()
|
||||
field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
|
@ -57,6 +57,9 @@ class CustomFieldTable(NetBoxTable):
|
||||
description = columns.MarkdownColumn(
|
||||
verbose_name=_('Description')
|
||||
)
|
||||
related_object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Related Object Type')
|
||||
)
|
||||
choice_set = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Choice Set')
|
||||
@ -73,9 +76,9 @@ class CustomFieldTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'object_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
||||
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set',
|
||||
'choices', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
|
||||
'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'weight', 'choice_set', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
|
@ -350,7 +350,7 @@ class CustomFieldTest(TestCase):
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
related_object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
required=False
|
||||
)
|
||||
cf.object_types.set([self.object_type])
|
||||
@ -382,7 +382,7 @@ class CustomFieldTest(TestCase):
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
related_object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
required=False
|
||||
)
|
||||
cf.object_types.set([self.object_type])
|
||||
@ -498,16 +498,28 @@ class CustomFieldTest(TestCase):
|
||||
).full_clean()
|
||||
|
||||
# Object
|
||||
CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean()
|
||||
CustomField(
|
||||
name='test',
|
||||
type='object',
|
||||
required=True,
|
||||
related_object_type=object_type,
|
||||
default=site.pk
|
||||
).full_clean()
|
||||
with (self.assertRaises(ValidationError)):
|
||||
CustomField(
|
||||
name='test',
|
||||
type='object',
|
||||
required=True,
|
||||
related_object_type=object_type,
|
||||
default="xxx"
|
||||
).full_clean()
|
||||
|
||||
# Multi-object
|
||||
CustomField(
|
||||
name='test',
|
||||
type='multiobject',
|
||||
required=True,
|
||||
object_type=object_type,
|
||||
related_object_type=object_type,
|
||||
default=[site.pk]
|
||||
).full_clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
@ -515,7 +527,7 @@ class CustomFieldTest(TestCase):
|
||||
name='test',
|
||||
type='multiobject',
|
||||
required=True,
|
||||
object_type=object_type,
|
||||
related_object_type=object_type,
|
||||
default=["xxx"]
|
||||
).full_clean()
|
||||
|
||||
@ -581,13 +593,13 @@ class CustomFieldAPITest(APITestCase):
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
name='object_field',
|
||||
object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
related_object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
default=vlans[0].pk,
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
name='multiobject_field',
|
||||
object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
related_object_type=ObjectType.objects.get_for_model(VLAN),
|
||||
default=[vlans[0].pk, vlans[1].pk],
|
||||
),
|
||||
)
|
||||
@ -1410,7 +1422,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
cf = CustomField(
|
||||
name='cf11',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(Manufacturer)
|
||||
related_object_type=ObjectType.objects.get_for_model(Manufacturer)
|
||||
)
|
||||
cf.save()
|
||||
cf.object_types.set([object_type])
|
||||
@ -1419,7 +1431,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
cf = CustomField(
|
||||
name='cf12',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(Manufacturer)
|
||||
related_object_type=ObjectType.objects.get_for_model(Manufacturer)
|
||||
)
|
||||
cf.save()
|
||||
cf.object_types.set([object_type])
|
||||
|
@ -86,6 +86,16 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
ui_editable=CustomFieldUIEditableChoices.HIDDEN,
|
||||
choice_set=choice_sets[1]
|
||||
),
|
||||
CustomField(
|
||||
name='Custom Field 6',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
related_object_type=ObjectType.objects.get_by_natural_key('dcim', 'site'),
|
||||
required=False,
|
||||
weight=600,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||
ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
|
||||
ui_editable=CustomFieldUIEditableChoices.HIDDEN
|
||||
),
|
||||
)
|
||||
CustomField.objects.bulk_create(custom_fields)
|
||||
custom_fields[0].object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'site'))
|
||||
@ -108,6 +118,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_related_object_type(self):
|
||||
params = {'related_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_required(self):
|
||||
params = {'required': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
@ -62,14 +62,14 @@ class CustomFieldModelFormTest(TestCase):
|
||||
cf_object = CustomField.objects.create(
|
||||
name='object',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(Site)
|
||||
related_object_type=ObjectType.objects.get_for_model(Site)
|
||||
)
|
||||
cf_object.object_types.set([object_type])
|
||||
|
||||
cf_multiobject = CustomField.objects.create(
|
||||
name='multiobject',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
object_type=ObjectType.objects.get_for_model(Site)
|
||||
related_object_type=ObjectType.objects.get_for_model(Site)
|
||||
)
|
||||
cf_multiobject.object_types.set([object_type])
|
||||
|
||||
|
@ -54,7 +54,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,object_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable',
|
||||
'name,label,type,object_types,related_object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable',
|
||||
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes',
|
||||
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes',
|
||||
'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes',
|
||||
|
@ -17,7 +17,9 @@
|
||||
<th scope="row">Type</th>
|
||||
<td>
|
||||
{{ object.get_type_display }}
|
||||
{% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
|
||||
{% if object.related_object_type %}
|
||||
({{ object.related_object_type.model|bettertitle }})
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
Reference in New Issue
Block a user