mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-20 02:06:42 -06:00
Merge pull request #5145 from netbox-community/4878-custom-fields
4878 custom fields
This commit is contained in:
commit
dbfb9b2cee
@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
|
|||||||
>>> vlan
|
>>> vlan
|
||||||
<VLAN: 123 (BetterName)>
|
<VLAN: 123 (BetterName)>
|
||||||
>>> vlan.delete()
|
>>> vlan.delete()
|
||||||
(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1})
|
(1, {'ipam.VLAN': 1})
|
||||||
```
|
```
|
||||||
|
|
||||||
To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
|
To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
|
||||||
@ -194,9 +194,9 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
|
|||||||
>>> Device.objects.filter(name__icontains='test').count()
|
>>> Device.objects.filter(name__icontains='test').count()
|
||||||
27
|
27
|
||||||
>>> Device.objects.filter(name__icontains='test').delete()
|
>>> Device.objects.filter(name__icontains='test').delete()
|
||||||
(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0,
|
(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
|
||||||
'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27,
|
'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
|
||||||
'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
22
netbox/circuits/migrations/0020_custom_field_data.py
Normal file
22
netbox/circuits/migrations/0020_custom_field_data.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0019_nullbooleanfield_to_booleanfield'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuit',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='provider',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -1,4 +1,3 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -61,11 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -186,11 +180,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = CircuitQuerySet.as_manager()
|
objects = CircuitQuerySet.as_manager()
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
37
netbox/dcim/migrations/0116_custom_field_data.py
Normal file
37
netbox/dcim/migrations/0116_custom_field_data.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0115_rackreservation_order'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='powerfeed',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rack',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -134,11 +134,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -584,11 +579,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
images = GenericRelation(
|
images = GenericRelation(
|
||||||
to='extras.ImageAttachment'
|
to='extras.ImageAttachment'
|
||||||
)
|
)
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -144,11 +143,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
@ -261,11 +261,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
images = GenericRelation(
|
images = GenericRelation(
|
||||||
to='extras.ImageAttachment'
|
to='extras.ImageAttachment'
|
||||||
)
|
)
|
||||||
|
@ -183,11 +183,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
images = GenericRelation(
|
images = GenericRelation(
|
||||||
to='extras.ImageAttachment'
|
to='extras.ImageAttachment'
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,8 @@ from django import forms
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from utilities.forms import LaxURLField
|
from utilities.forms import LaxURLField
|
||||||
from .models import CustomField, CustomFieldChoice, CustomLink, ExportTemplate, JobResult, Webhook
|
from .choices import CustomFieldTypeChoices
|
||||||
|
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
|
||||||
|
|
||||||
|
|
||||||
def order_content_types(field):
|
def order_content_types(field):
|
||||||
@ -80,22 +81,38 @@ class CustomFieldForm(forms.ModelForm):
|
|||||||
|
|
||||||
order_content_types(self.fields['obj_type'])
|
order_content_types(self.fields['obj_type'])
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
class CustomFieldChoiceAdmin(admin.TabularInline):
|
# Validate selection choices
|
||||||
model = CustomFieldChoice
|
if self.cleaned_data['type'] == CustomFieldTypeChoices.TYPE_SELECT and len(self.cleaned_data['choices']) < 2:
|
||||||
extra = 5
|
raise forms.ValidationError({
|
||||||
|
'choices': 'Selection fields must specify at least two choices.'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomField)
|
@admin.register(CustomField)
|
||||||
class CustomFieldAdmin(admin.ModelAdmin):
|
class CustomFieldAdmin(admin.ModelAdmin):
|
||||||
inlines = [CustomFieldChoiceAdmin]
|
actions = None
|
||||||
|
form = CustomFieldForm
|
||||||
list_display = [
|
list_display = [
|
||||||
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
|
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
'type', 'required', 'obj_type',
|
'type', 'required', 'obj_type',
|
||||||
]
|
]
|
||||||
form = CustomFieldForm
|
fieldsets = (
|
||||||
|
('Custom Field', {
|
||||||
|
'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
|
||||||
|
}),
|
||||||
|
('Assignment', {
|
||||||
|
'description': 'A custom field must be assigned to one or more object types.',
|
||||||
|
'fields': ('obj_type',)
|
||||||
|
}),
|
||||||
|
('Choices', {
|
||||||
|
'description': 'A selection field must have two or more choices assigned to it.',
|
||||||
|
'fields': ('choices',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
def models(self, obj):
|
def models(self, obj):
|
||||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.db import transaction
|
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CreateOnlyDefault
|
from rest_framework.fields import CreateOnlyDefault, Field
|
||||||
|
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
|
from extras.models import CustomField
|
||||||
from utilities.api import ValidatedModelSerializer
|
from utilities.api import ValidatedModelSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -38,12 +35,6 @@ class CustomFieldDefaultValues:
|
|||||||
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||||
# TODO: Fix default value assignment for boolean custom fields
|
# TODO: Fix default value assignment for boolean custom fields
|
||||||
field_value = False if field.default.lower() == 'false' else bool(field.default)
|
field_value = False if field.default.lower() == 'false' else bool(field.default)
|
||||||
elif field.type == CustomFieldTypeChoices.TYPE_SELECT:
|
|
||||||
try:
|
|
||||||
field_value = field.choices.get(value=field.default).pk
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
# Invalid default value
|
|
||||||
field_value = None
|
|
||||||
else:
|
else:
|
||||||
field_value = field.default
|
field_value = field.default
|
||||||
value[field.name] = field_value
|
value[field.name] = field_value
|
||||||
@ -53,26 +44,35 @@ class CustomFieldDefaultValues:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldsSerializer(serializers.BaseSerializer):
|
class CustomFieldsDataField(Field):
|
||||||
|
|
||||||
|
def _get_custom_fields(self):
|
||||||
|
"""
|
||||||
|
Cache CustomFields assigned to this model to avoid redundant database queries
|
||||||
|
"""
|
||||||
|
if not hasattr(self, '_custom_fields'):
|
||||||
|
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
|
||||||
|
self._custom_fields = CustomField.objects.filter(obj_type=content_type)
|
||||||
|
return self._custom_fields
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
return obj
|
return {
|
||||||
|
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
|
||||||
|
}
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
# If updating an existing instance, start with existing custom_field_data
|
||||||
|
if self.parent.instance:
|
||||||
|
data = {**self.parent.instance.custom_field_data, **data}
|
||||||
|
|
||||||
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
|
custom_fields = {field.name: field for field in self._get_custom_fields()}
|
||||||
custom_fields = {
|
|
||||||
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
for field_name, value in data.items():
|
for field_name, value in data.items():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cf = custom_fields[field_name]
|
cf = custom_fields[field_name]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValidationError(
|
raise ValidationError(f"Invalid custom field name: {field_name}")
|
||||||
"Invalid custom field for {} objects: {}".format(content_type, field_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Data validation
|
# Data validation
|
||||||
if value not in [None, '']:
|
if value not in [None, '']:
|
||||||
@ -82,15 +82,11 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
|
|||||||
try:
|
try:
|
||||||
int(value)
|
int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError(
|
raise ValidationError(f"Invalid value for integer field {field_name}: {value}")
|
||||||
"Invalid value for integer field {}: {}".format(field_name, value)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate boolean
|
# Validate boolean
|
||||||
if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
||||||
raise ValidationError(
|
raise ValidationError(f"Invalid value for boolean field {field_name}: {value}")
|
||||||
"Invalid value for boolean field {}: {}".format(field_name, value)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate date
|
# Validate date
|
||||||
if cf.type == CustomFieldTypeChoices.TYPE_DATE:
|
if cf.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||||
@ -98,25 +94,16 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
|
|||||||
datetime.strptime(value, '%Y-%m-%d')
|
datetime.strptime(value, '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
|
f"Invalid date for field {field_name}: {value}. (Required format is YYYY-MM-DD.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selected choice
|
# Validate selected choice
|
||||||
if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
|
if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
try:
|
if value not in cf.choices:
|
||||||
value = int(value)
|
raise ValidationError(f"Invalid choice for field {field_name}: {value}")
|
||||||
except ValueError:
|
|
||||||
raise ValidationError(
|
|
||||||
"{}: Choice selections must be passed as integers.".format(field_name)
|
|
||||||
)
|
|
||||||
valid_choices = [c.pk for c in cf.choices.all()]
|
|
||||||
if value not in valid_choices:
|
|
||||||
raise ValidationError(
|
|
||||||
"Invalid choice for field {}: {}".format(field_name, value)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif cf.required:
|
elif cf.required:
|
||||||
raise ValidationError("Required field {} cannot be empty.".format(field_name))
|
raise ValidationError(f"Required field {field_name} cannot be empty.")
|
||||||
|
|
||||||
# Check for missing required fields
|
# Check for missing required fields
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
@ -133,8 +120,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
|||||||
"""
|
"""
|
||||||
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
||||||
"""
|
"""
|
||||||
custom_fields = CustomFieldsSerializer(
|
custom_fields = CustomFieldsDataField(
|
||||||
required=False,
|
source='custom_field_data',
|
||||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -148,70 +135,13 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
|||||||
fields = CustomField.objects.filter(obj_type=content_type)
|
fields = CustomField.objects.filter(obj_type=content_type)
|
||||||
|
|
||||||
# Populate CustomFieldValues for each instance from database
|
# Populate CustomFieldValues for each instance from database
|
||||||
try:
|
if type(self.instance) in (list, tuple):
|
||||||
for obj in self.instance:
|
for obj in self.instance:
|
||||||
self._populate_custom_fields(obj, fields)
|
self._populate_custom_fields(obj, fields)
|
||||||
except TypeError:
|
else:
|
||||||
self._populate_custom_fields(self.instance, fields)
|
self._populate_custom_fields(self.instance, fields)
|
||||||
|
|
||||||
def _populate_custom_fields(self, instance, custom_fields):
|
def _populate_custom_fields(self, instance, custom_fields):
|
||||||
instance.custom_fields = {}
|
instance.custom_fields = {}
|
||||||
for field in custom_fields:
|
for field in custom_fields:
|
||||||
value = instance.cf.get(field.name)
|
instance.custom_fields[field.name] = instance.cf.get(field.name)
|
||||||
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value:
|
|
||||||
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
|
|
||||||
else:
|
|
||||||
instance.custom_fields[field.name] = value
|
|
||||||
|
|
||||||
def _save_custom_fields(self, instance, custom_fields):
|
|
||||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
|
||||||
for field_name, value in custom_fields.items():
|
|
||||||
custom_field = CustomField.objects.get(name=field_name)
|
|
||||||
CustomFieldValue.objects.update_or_create(
|
|
||||||
field=custom_field,
|
|
||||||
obj_type=content_type,
|
|
||||||
obj_id=instance.pk,
|
|
||||||
defaults={'serialized_value': custom_field.serialize_value(value)},
|
|
||||||
)
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
|
||||||
|
|
||||||
# Save custom fields
|
|
||||||
custom_fields = validated_data.get('custom_fields')
|
|
||||||
if custom_fields is not None:
|
|
||||||
self._save_custom_fields(instance, custom_fields)
|
|
||||||
instance.custom_fields = custom_fields
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
|
|
||||||
custom_fields = validated_data.get('custom_fields')
|
|
||||||
instance._cf = custom_fields
|
|
||||||
|
|
||||||
instance = super().update(instance, validated_data)
|
|
||||||
|
|
||||||
# Save custom fields
|
|
||||||
if custom_fields is not None:
|
|
||||||
self._save_custom_fields(instance, custom_fields)
|
|
||||||
instance.custom_fields = custom_fields
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Imitate utilities.api.ChoiceFieldSerializer
|
|
||||||
"""
|
|
||||||
value = serializers.IntegerField(source='pk')
|
|
||||||
label = serializers.CharField(source='value')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = CustomFieldChoice
|
|
||||||
fields = ['value', 'label']
|
|
||||||
|
@ -5,9 +5,6 @@ from . import views
|
|||||||
router = OrderedDefaultRouter()
|
router = OrderedDefaultRouter()
|
||||||
router.APIRootView = views.ExtrasRootView
|
router.APIRootView = views.ExtrasRootView
|
||||||
|
|
||||||
# Custom field choices
|
|
||||||
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
|
|
||||||
|
|
||||||
# Export templates
|
# Export templates
|
||||||
router.register('export-templates', views.ExportTemplateViewSet)
|
router.register('export-templates', views.ExportTemplateViewSet)
|
||||||
|
|
||||||
|
@ -14,9 +14,7 @@ from rq import Worker
|
|||||||
|
|
||||||
from extras import filters
|
from extras import filters
|
||||||
from extras.choices import JobResultStatusChoices
|
from extras.choices import JobResultStatusChoices
|
||||||
from extras.models import (
|
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag
|
||||||
ConfigContext, CustomFieldChoice, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
|
|
||||||
)
|
|
||||||
from extras.reports import get_report, get_reports, run_report
|
from extras.reports import get_report, get_reports, run_report
|
||||||
from extras.scripts import get_script, get_scripts, run_script
|
from extras.scripts import get_script, get_scripts, run_script
|
||||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||||
@ -34,36 +32,6 @@ class ExtrasRootView(APIRootView):
|
|||||||
return 'Extras'
|
return 'Extras'
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Custom field choices
|
|
||||||
#
|
|
||||||
|
|
||||||
class CustomFieldChoicesViewSet(ViewSet):
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self._fields = OrderedDict()
|
|
||||||
|
|
||||||
for cfc in CustomFieldChoice.objects.all():
|
|
||||||
self._fields.setdefault(cfc.field.name, {})
|
|
||||||
self._fields[cfc.field.name][cfc.value] = cfc.pk
|
|
||||||
|
|
||||||
def list(self, request):
|
|
||||||
return Response(self._fields)
|
|
||||||
|
|
||||||
def retrieve(self, request, pk):
|
|
||||||
if pk not in self._fields:
|
|
||||||
raise Http404
|
|
||||||
return Response(self._fields[pk])
|
|
||||||
|
|
||||||
def get_view_name(self):
|
|
||||||
return "Custom Field choices"
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Custom fields
|
# Custom fields
|
||||||
#
|
#
|
||||||
@ -77,26 +45,14 @@ class CustomFieldModelViewSet(ModelViewSet):
|
|||||||
|
|
||||||
# Gather all custom fields for the model
|
# Gather all custom fields for the model
|
||||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||||
custom_fields = content_type.custom_fields.prefetch_related('choices')
|
custom_fields = content_type.custom_fields.all()
|
||||||
|
|
||||||
# Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
|
|
||||||
custom_field_choices = {}
|
|
||||||
for field in custom_fields:
|
|
||||||
for cfc in field.choices.all():
|
|
||||||
custom_field_choices[cfc.id] = cfc.value
|
|
||||||
custom_field_choices = custom_field_choices
|
|
||||||
|
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context.update({
|
context.update({
|
||||||
'custom_fields': custom_fields,
|
'custom_fields': custom_fields,
|
||||||
'custom_field_choices': custom_field_choices,
|
|
||||||
})
|
})
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
# Prefetch custom field values
|
|
||||||
return super().get_queryset().prefetch_related('custom_field_values__field')
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Export templates
|
# Export templates
|
||||||
|
@ -22,15 +22,20 @@ __all__ = (
|
|||||||
'TagFilterSet',
|
'TagFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
EXACT_FILTER_TYPES = (
|
||||||
|
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||||
|
CustomFieldTypeChoices.TYPE_DATE,
|
||||||
|
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||||
|
CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilter(django_filters.Filter):
|
class CustomFieldFilter(django_filters.Filter):
|
||||||
"""
|
"""
|
||||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, custom_field, *args, **kwargs):
|
def __init__(self, custom_field, *args, **kwargs):
|
||||||
self.cf_type = custom_field.type
|
self.custom_field = custom_field
|
||||||
self.filter_logic = custom_field.filter_logic
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter(self, queryset, value):
|
def filter(self, queryset, value):
|
||||||
@ -39,44 +44,22 @@ class CustomFieldFilter(django_filters.Filter):
|
|||||||
if value is None or not value.strip():
|
if value is None or not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
# Selection fields get special treatment (values must be integers)
|
|
||||||
if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT:
|
|
||||||
try:
|
|
||||||
# Treat 0 as None
|
|
||||||
if int(value) == 0:
|
|
||||||
return queryset.exclude(
|
|
||||||
custom_field_values__field__name=self.field_name,
|
|
||||||
)
|
|
||||||
# Match on exact CustomFieldChoice PK
|
|
||||||
else:
|
|
||||||
return queryset.filter(
|
|
||||||
custom_field_values__field__name=self.field_name,
|
|
||||||
custom_field_values__serialized_value=value,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
return queryset.none()
|
|
||||||
|
|
||||||
# Apply the assigned filter logic (exact or loose)
|
# Apply the assigned filter logic (exact or loose)
|
||||||
if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or
|
if (
|
||||||
self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT):
|
self.custom_field.type in EXACT_FILTER_TYPES or
|
||||||
queryset = queryset.filter(
|
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||||
custom_field_values__field__name=self.field_name,
|
):
|
||||||
custom_field_values__serialized_value=value
|
kwargs = {f'custom_field_data__{self.field_name}': value}
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(
|
kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
|
||||||
custom_field_values__field__name=self.field_name,
|
|
||||||
custom_field_values__serialized_value__icontains=value
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset
|
return queryset.filter(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||||
"""
|
"""
|
||||||
Dynamically add a Filter for each CustomField applicable to the parent model.
|
Dynamically add a Filter for each CustomField applicable to the parent model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from utilities.forms import (
|
|||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup
|
from virtualization.models import Cluster, ClusterGroup
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
|
from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -25,78 +25,34 @@ class CustomFieldModelForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
|
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||||
self.custom_fields = []
|
self.custom_fields = []
|
||||||
self.custom_field_values = {}
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if self.instance._cf is None:
|
|
||||||
self.instance._cf = {}
|
|
||||||
|
|
||||||
self._append_customfield_fields()
|
self._append_customfield_fields()
|
||||||
|
|
||||||
def _append_customfield_fields(self):
|
def _append_customfield_fields(self):
|
||||||
"""
|
"""
|
||||||
Append form fields for all CustomFields assigned to this model.
|
Append form fields for all CustomFields assigned to this model.
|
||||||
"""
|
"""
|
||||||
# Retrieve initial CustomField values for the instance
|
|
||||||
if self.instance.pk:
|
|
||||||
for cfv in CustomFieldValue.objects.filter(
|
|
||||||
obj_type=self.obj_type,
|
|
||||||
obj_id=self.instance.pk
|
|
||||||
).prefetch_related('field'):
|
|
||||||
self.custom_field_values[cfv.field.name] = cfv.serialized_value
|
|
||||||
|
|
||||||
# Append form fields; assign initial values if modifying and existing object
|
# Append form fields; assign initial values if modifying and existing object
|
||||||
for cf in CustomField.objects.filter(obj_type=self.obj_type):
|
for cf in CustomField.objects.filter(obj_type=self.obj_type):
|
||||||
field_name = 'cf_{}'.format(cf.name)
|
field_name = 'cf_{}'.format(cf.name)
|
||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
||||||
value = self.custom_field_values.get(cf.name)
|
self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name)
|
||||||
self.fields[field_name].initial = value
|
|
||||||
self.instance._cf[cf.name] = value
|
|
||||||
else:
|
else:
|
||||||
self.fields[field_name] = cf.to_form_field()
|
self.fields[field_name] = cf.to_form_field()
|
||||||
self.instance._cf[cf.name] = self.fields[field_name].initial
|
|
||||||
|
|
||||||
# Annotate the field in the list of CustomField form fields
|
# Annotate the field in the list of CustomField form fields
|
||||||
self.custom_fields.append(field_name)
|
self.custom_fields.append(field_name)
|
||||||
|
|
||||||
def _save_custom_fields(self):
|
|
||||||
|
|
||||||
for field_name in self.custom_fields:
|
|
||||||
try:
|
|
||||||
cfv = CustomFieldValue.objects.prefetch_related('field').get(
|
|
||||||
field=self.fields[field_name].model,
|
|
||||||
obj_type=self.obj_type,
|
|
||||||
obj_id=self.instance.pk
|
|
||||||
)
|
|
||||||
except CustomFieldValue.DoesNotExist:
|
|
||||||
# Skip this field if none exists already and its value is empty
|
|
||||||
if self.cleaned_data[field_name] in [None, '']:
|
|
||||||
continue
|
|
||||||
cfv = CustomFieldValue(
|
|
||||||
field=self.fields[field_name].model,
|
|
||||||
obj_type=self.obj_type,
|
|
||||||
obj_id=self.instance.pk
|
|
||||||
)
|
|
||||||
cfv.value = self.cleaned_data[field_name]
|
|
||||||
cfv.save()
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|
||||||
# Cache custom field values on object prior to save to ensure change logging
|
# Save custom field data on instance
|
||||||
for cf_name in self.custom_fields:
|
for cf_name in self.custom_fields:
|
||||||
self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
||||||
|
|
||||||
obj = super().save(commit)
|
return super().save(commit)
|
||||||
|
|
||||||
# Handle custom fields the same way we do M2M fields
|
|
||||||
if commit:
|
|
||||||
self._save_custom_fields()
|
|
||||||
else:
|
|
||||||
obj.save_custom_fields = self._save_custom_fields
|
|
||||||
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||||
|
35
netbox/extras/migrations/0050_customfield_add_choices.py
Normal file
35
netbox/extras/migrations/0050_customfield_add_choices.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0049_remove_graph'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Rename reverse relation on CustomFieldChoice
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customfieldchoice',
|
||||||
|
name='field',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
limit_choices_to={'type': 'select'},
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='_choices',
|
||||||
|
to='extras.customfield'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add choices field to CustomField
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='choices',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(max_length=100),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
size=None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
73
netbox/extras/migrations/0051_migrate_customfields.py
Normal file
73
netbox/extras/migrations/0051_migrate_customfields.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
from extras.choices import CustomFieldTypeChoices
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize_value(field, value):
|
||||||
|
"""
|
||||||
|
Convert serialized values to JSON equivalents.
|
||||||
|
"""
|
||||||
|
if field.type in (CustomFieldTypeChoices.TYPE_INTEGER):
|
||||||
|
return int(value)
|
||||||
|
if field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||||
|
return bool(int(value))
|
||||||
|
if field.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
|
return field._choices.get(pk=int(value)).value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_customfieldchoices(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
|
||||||
|
the CustomField instance.
|
||||||
|
"""
|
||||||
|
CustomField = apps.get_model('extras', 'CustomField')
|
||||||
|
CustomFieldChoice = apps.get_model('extras', 'CustomFieldChoice')
|
||||||
|
|
||||||
|
for cf in CustomField.objects.filter(type='select'):
|
||||||
|
cf.choices = [
|
||||||
|
cfc.value for cfc in CustomFieldChoice.objects.filter(field=cf).order_by('weight', 'value')
|
||||||
|
]
|
||||||
|
cf.save()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_customfieldvalues(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Copy data from CustomFieldValues into the custom_field_data JSON field on each model instance.
|
||||||
|
"""
|
||||||
|
CustomFieldValue = apps.get_model('extras', 'CustomFieldValue')
|
||||||
|
|
||||||
|
for cfv in CustomFieldValue.objects.prefetch_related('field').exclude(serialized_value=''):
|
||||||
|
model = apps.get_model(cfv.obj_type.app_label, cfv.obj_type.model)
|
||||||
|
|
||||||
|
# Read and update custom field value for each instance
|
||||||
|
# TODO: This can be done more efficiently once .update() is supported for JSON fields
|
||||||
|
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
|
||||||
|
try:
|
||||||
|
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
|
||||||
|
raise e
|
||||||
|
model.objects.filter(pk=cfv.obj_id).update(**cf_data)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0020_custom_field_data'),
|
||||||
|
('dcim', '0116_custom_field_data'),
|
||||||
|
('extras', '0050_customfield_add_choices'),
|
||||||
|
('ipam', '0038_custom_field_data'),
|
||||||
|
('secrets', '0010_custom_field_data'),
|
||||||
|
('tenancy', '0010_custom_field_data'),
|
||||||
|
('virtualization', '0018_custom_field_data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
code=migrate_customfieldchoices
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=migrate_customfieldvalues
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0051_migrate_customfields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='CustomFieldChoice',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='CustomFieldValue',
|
||||||
|
),
|
||||||
|
]
|
@ -1,5 +1,5 @@
|
|||||||
from .change_logging import ChangeLoggedModel, ObjectChange
|
from .change_logging import ChangeLoggedModel, ObjectChange
|
||||||
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
|
from .customfields import CustomField, CustomFieldModel
|
||||||
from .models import (
|
from .models import (
|
||||||
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
|
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
|
||||||
Webhook,
|
Webhook,
|
||||||
@ -11,9 +11,7 @@ __all__ = (
|
|||||||
'ConfigContext',
|
'ConfigContext',
|
||||||
'ConfigContextModel',
|
'ConfigContextModel',
|
||||||
'CustomField',
|
'CustomField',
|
||||||
'CustomFieldChoice',
|
|
||||||
'CustomFieldModel',
|
'CustomFieldModel',
|
||||||
'CustomFieldValue',
|
|
||||||
'CustomLink',
|
'CustomLink',
|
||||||
'ExportTemplate',
|
'ExportTemplate',
|
||||||
'ImageAttachment',
|
'ImageAttachment',
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
@ -12,49 +12,34 @@ from extras.choices import *
|
|||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Custom fields
|
|
||||||
#
|
|
||||||
|
|
||||||
class CustomFieldModel(models.Model):
|
class CustomFieldModel(models.Model):
|
||||||
|
"""
|
||||||
|
Abstract class for any model which may have custom fields associated with it.
|
||||||
|
"""
|
||||||
|
custom_field_data = models.JSONField(
|
||||||
|
encoder=DjangoJSONEncoder,
|
||||||
|
blank=True,
|
||||||
|
default=dict
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def __init__(self, *args, custom_fields=None, **kwargs):
|
|
||||||
self._cf = custom_fields
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def cache_custom_fields(self):
|
|
||||||
"""
|
|
||||||
Cache all custom field values for this instance
|
|
||||||
"""
|
|
||||||
self._cf = {
|
|
||||||
field.name: value for field, value in self.get_custom_fields().items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cf(self):
|
def cf(self):
|
||||||
"""
|
"""
|
||||||
Name-based CustomFieldValue accessor for use in templates
|
Convenience wrapper for custom field data.
|
||||||
"""
|
"""
|
||||||
if self._cf is None:
|
return self.custom_field_data
|
||||||
self.cache_custom_fields()
|
|
||||||
return self._cf
|
|
||||||
|
|
||||||
def get_custom_fields(self):
|
def get_custom_fields(self):
|
||||||
"""
|
"""
|
||||||
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
||||||
"""
|
"""
|
||||||
fields = CustomField.objects.get_for_model(self)
|
fields = CustomField.objects.get_for_model(self)
|
||||||
|
return OrderedDict([
|
||||||
# If the object exists, populate its custom fields with values
|
(field, self.custom_field_data.get(field.name)) for field in fields
|
||||||
if hasattr(self, 'pk'):
|
])
|
||||||
values = self.custom_field_values.all()
|
|
||||||
values_dict = {cfv.field_id: cfv.value for cfv in values}
|
|
||||||
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
|
|
||||||
else:
|
|
||||||
return OrderedDict([(field, None) for field in fields])
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldManager(models.Manager):
|
class CustomFieldManager(models.Manager):
|
||||||
@ -116,6 +101,12 @@ class CustomField(models.Model):
|
|||||||
default=100,
|
default=100,
|
||||||
help_text='Fields with higher weights appear lower in a form.'
|
help_text='Fields with higher weights appear lower in a form.'
|
||||||
)
|
)
|
||||||
|
choices = ArrayField(
|
||||||
|
base_field=models.CharField(max_length=100),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Comma-separated list of available choices (for selection fields)'
|
||||||
|
)
|
||||||
|
|
||||||
objects = CustomFieldManager()
|
objects = CustomFieldManager()
|
||||||
|
|
||||||
@ -125,41 +116,29 @@ class CustomField(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.label or self.name.replace('_', ' ').capitalize()
|
return self.label or self.name.replace('_', ' ').capitalize()
|
||||||
|
|
||||||
def serialize_value(self, value):
|
def remove_stale_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
Serialize the given value to a string suitable for storage as a CustomFieldValue
|
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||||
|
no longer assigned to a model, or because it has been deleted).
|
||||||
"""
|
"""
|
||||||
if value is None:
|
for ct in content_types:
|
||||||
return ''
|
model = ct.model_class()
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
|
||||||
return str(int(bool(value)))
|
del(obj.custom_field_data[self.name])
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
obj.save()
|
||||||
# Could be date/datetime object or string
|
|
||||||
try:
|
|
||||||
return value.strftime('%Y-%m-%d')
|
|
||||||
except AttributeError:
|
|
||||||
return value
|
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
|
||||||
# Could be ModelChoiceField or TypedChoiceField
|
|
||||||
return str(value.id) if hasattr(value, 'id') else str(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def deserialize_value(self, serialized_value):
|
def clean(self):
|
||||||
"""
|
# Choices can be set only on selection fields
|
||||||
Convert a string into the object it represents depending on the type of field
|
if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
"""
|
raise ValidationError({
|
||||||
if serialized_value == '':
|
'choices': "Choices may be set only for selection-type custom fields."
|
||||||
return None
|
})
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
|
||||||
return int(serialized_value)
|
# A selection field's default (if any) must be present in its available choices
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
|
||||||
return bool(int(serialized_value))
|
raise ValidationError({
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
'default': f"The specified default value ({self.default}) is not listed as an available choice."
|
||||||
# Read date as YYYY-MM-DD
|
})
|
||||||
return date(*[int(n) for n in serialized_value.split('-')])
|
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
|
||||||
return self.choices.get(pk=int(serialized_value))
|
|
||||||
return serialized_value
|
|
||||||
|
|
||||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||||
"""
|
"""
|
||||||
@ -180,15 +159,11 @@ class CustomField(models.Model):
|
|||||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||||
choices = (
|
choices = (
|
||||||
(None, '---------'),
|
(None, '---------'),
|
||||||
(1, 'True'),
|
(True, 'True'),
|
||||||
(0, 'False'),
|
(False, 'False'),
|
||||||
)
|
)
|
||||||
if initial is not None and initial.lower() in ['true', 'yes', '1']:
|
if initial is not None:
|
||||||
initial = 1
|
initial = bool(initial)
|
||||||
elif initial is not None and initial.lower() in ['false', 'no', '0']:
|
|
||||||
initial = 0
|
|
||||||
else:
|
|
||||||
initial = None
|
|
||||||
field = forms.NullBooleanField(
|
field = forms.NullBooleanField(
|
||||||
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||||
)
|
)
|
||||||
@ -199,16 +174,14 @@ class CustomField(models.Model):
|
|||||||
|
|
||||||
# Select
|
# Select
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
|
choices = [(c, c) for c in self.choices]
|
||||||
|
|
||||||
if not required:
|
if not required:
|
||||||
choices = add_blank_choice(choices)
|
choices = add_blank_choice(choices)
|
||||||
|
|
||||||
# Set the initial value to the PK of the default choice, if any
|
# Set the initial value to the first available choice (if any)
|
||||||
if set_initial:
|
if set_initial and self.choices:
|
||||||
default_choice = self.choices.filter(value=self.default).first()
|
initial = self.choices[0]
|
||||||
if default_choice:
|
|
||||||
initial = default_choice.pk
|
|
||||||
|
|
||||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||||
field = field_class(
|
field = field_class(
|
||||||
@ -224,87 +197,8 @@ class CustomField(models.Model):
|
|||||||
field = forms.CharField(max_length=255, required=required, initial=initial)
|
field = forms.CharField(max_length=255, required=required, initial=initial)
|
||||||
|
|
||||||
field.model = self
|
field.model = self
|
||||||
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
|
field.label = str(self)
|
||||||
if self.description:
|
if self.description:
|
||||||
field.help_text = self.description
|
field.help_text = self.description
|
||||||
|
|
||||||
return field
|
return field
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldValue(models.Model):
|
|
||||||
field = models.ForeignKey(
|
|
||||||
to='extras.CustomField',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='values'
|
|
||||||
)
|
|
||||||
obj_type = models.ForeignKey(
|
|
||||||
to=ContentType,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='+'
|
|
||||||
)
|
|
||||||
obj_id = models.PositiveIntegerField()
|
|
||||||
obj = GenericForeignKey(
|
|
||||||
ct_field='obj_type',
|
|
||||||
fk_field='obj_id'
|
|
||||||
)
|
|
||||||
serialized_value = models.CharField(
|
|
||||||
max_length=255
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
|
|
||||||
unique_together = ('field', 'obj_type', 'obj_id')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '{} {}'.format(self.obj, self.field)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def value(self):
|
|
||||||
return self.field.deserialize_value(self.serialized_value)
|
|
||||||
|
|
||||||
@value.setter
|
|
||||||
def value(self, value):
|
|
||||||
self.serialized_value = self.field.serialize_value(value)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Delete this object if it no longer has a value to store
|
|
||||||
if self.pk and self.value is None:
|
|
||||||
self.delete()
|
|
||||||
else:
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldChoice(models.Model):
|
|
||||||
field = models.ForeignKey(
|
|
||||||
to='extras.CustomField',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='choices',
|
|
||||||
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
|
|
||||||
)
|
|
||||||
value = models.CharField(
|
|
||||||
max_length=100
|
|
||||||
)
|
|
||||||
weight = models.PositiveSmallIntegerField(
|
|
||||||
default=100,
|
|
||||||
help_text='Higher weights appear lower in the list'
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['field', 'weight', 'value']
|
|
||||||
unique_together = ['field', 'value']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
|
|
||||||
raise ValidationError("Custom field choices can only be assigned to selection fields.")
|
|
||||||
|
|
||||||
def delete(self, using=None, keep_parents=False):
|
|
||||||
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
|
|
||||||
pk = self.pk
|
|
||||||
super().delete(using, keep_parents)
|
|
||||||
CustomFieldValue.objects.filter(
|
|
||||||
field__type=CustomFieldTypeChoices.TYPE_SELECT,
|
|
||||||
serialized_value=str(pk)
|
|
||||||
).delete()
|
|
||||||
|
@ -1,26 +1,8 @@
|
|||||||
from collections import OrderedDict
|
from django.db.models import Q
|
||||||
|
|
||||||
from django.db.models import Q, QuerySet
|
|
||||||
|
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldQueryset:
|
|
||||||
"""
|
|
||||||
Annotate custom fields on objects within a QuerySet.
|
|
||||||
"""
|
|
||||||
def __init__(self, queryset, custom_fields):
|
|
||||||
self.queryset = queryset
|
|
||||||
self.model = queryset.model
|
|
||||||
self.custom_fields = custom_fields
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for obj in self.queryset:
|
|
||||||
values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
|
|
||||||
obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
|
|
||||||
yield obj
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextQuerySet(RestrictedQuerySet):
|
class ConfigContextQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
def get_for_object(self, obj):
|
def get_for_object(self, obj):
|
||||||
|
@ -3,12 +3,14 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from cacheops.signals import cache_invalidated, cache_read
|
from cacheops.signals import cache_invalidated, cache_read
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db.models.signals import m2m_changed, pre_delete
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||||
from prometheus_client import Counter
|
from prometheus_client import Counter
|
||||||
|
|
||||||
from .choices import ObjectChangeActionChoices
|
from .choices import ObjectChangeActionChoices
|
||||||
from .models import ObjectChange
|
from .models import CustomField, ObjectChange
|
||||||
from .webhooks import enqueue_webhooks
|
from .webhooks import enqueue_webhooks
|
||||||
|
|
||||||
|
|
||||||
@ -71,6 +73,29 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
|
|||||||
model_deletes.labels(instance._meta.model_name).inc()
|
model_deletes.labels(instance._meta.model_name).inc()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom fields
|
||||||
|
#
|
||||||
|
|
||||||
|
def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
|
||||||
|
"""
|
||||||
|
if action == 'post_remove':
|
||||||
|
instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_cf_deleted(instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle the cleanup of old custom field data when a CustomField is deleted.
|
||||||
|
"""
|
||||||
|
instance.remove_stale_data(instance.obj_type.all())
|
||||||
|
|
||||||
|
|
||||||
|
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.obj_type.through)
|
||||||
|
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Caching
|
# Caching
|
||||||
#
|
#
|
||||||
|
@ -5,7 +5,7 @@ from rest_framework import status
|
|||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag
|
from extras.models import CustomField, ObjectChange, Tag
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
from utilities.testing.utils import post_data
|
from utilities.testing.utils import post_data
|
||||||
from utilities.testing.views import ModelViewTestCase
|
from utilities.testing.views import ModelViewTestCase
|
||||||
@ -93,16 +93,14 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Test Site 1',
|
name='Test Site 1',
|
||||||
slug='test-site-1'
|
slug='test-site-1',
|
||||||
|
custom_field_data={
|
||||||
|
'my_field': 'ABC'
|
||||||
|
}
|
||||||
)
|
)
|
||||||
site.save()
|
site.save()
|
||||||
self.create_tags('Tag 1', 'Tag 2')
|
self.create_tags('Tag 1', 'Tag 2')
|
||||||
site.tags.set('Tag 1', 'Tag 2')
|
site.tags.set('Tag 1', 'Tag 2')
|
||||||
CustomFieldValue.objects.create(
|
|
||||||
field=CustomField.objects.get(name='my_field'),
|
|
||||||
obj=site,
|
|
||||||
value='ABC'
|
|
||||||
)
|
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
'path': self._get_url('delete', instance=site),
|
'path': self._get_url('delete', instance=site),
|
||||||
@ -209,15 +207,13 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Test Site 1',
|
name='Test Site 1',
|
||||||
slug='test-site-1'
|
slug='test-site-1',
|
||||||
|
custom_field_data={
|
||||||
|
'my_field': 'ABC'
|
||||||
|
}
|
||||||
)
|
)
|
||||||
site.save()
|
site.save()
|
||||||
site.tags.set(*Tag.objects.all()[:2])
|
site.tags.set(*Tag.objects.all()[:2])
|
||||||
CustomFieldValue.objects.create(
|
|
||||||
field=CustomField.objects.get(name='my_field'),
|
|
||||||
obj=site,
|
|
||||||
value='ABC'
|
|
||||||
)
|
|
||||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||||
self.add_permissions('dcim.delete_site')
|
self.add_permissions('dcim.delete_site')
|
||||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
from datetime import date
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -7,7 +5,7 @@ from rest_framework import status
|
|||||||
from dcim.forms import SiteCSVForm
|
from dcim.forms import SiteCSVForm
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
|
from extras.models import CustomField
|
||||||
from utilities.testing import APITestCase, TestCase
|
from utilities.testing import APITestCase, TestCase
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
|
|
||||||
@ -30,7 +28,7 @@ class CustomFieldTest(TestCase):
|
|||||||
{'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
|
{'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
|
||||||
{'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
|
{'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
|
||||||
{'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
|
{'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
|
||||||
{'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
|
{'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': '2016-06-23', 'empty_value': None},
|
||||||
{'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
|
{'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,18 +44,18 @@ class CustomFieldTest(TestCase):
|
|||||||
|
|
||||||
# Assign a value to the first Site
|
# Assign a value to the first Site
|
||||||
site = Site.objects.first()
|
site = Site.objects.first()
|
||||||
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
|
site.custom_field_data[cf.name] = data['field_value']
|
||||||
cfv.value = data['field_value']
|
site.save()
|
||||||
cfv.save()
|
|
||||||
|
|
||||||
# Retrieve the stored value
|
# Retrieve the stored value
|
||||||
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
|
site.refresh_from_db()
|
||||||
self.assertEqual(cfv.value, data['field_value'])
|
self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
|
||||||
|
|
||||||
# Delete the stored value
|
# Delete the stored value
|
||||||
cfv.value = data['empty_value']
|
site.custom_field_data.pop(cf.name)
|
||||||
cfv.save()
|
site.save()
|
||||||
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
|
site.refresh_from_db()
|
||||||
|
self.assertIsNone(site.custom_field_data.get(cf.name))
|
||||||
|
|
||||||
# Delete the custom field
|
# Delete the custom field
|
||||||
cf.delete()
|
cf.delete()
|
||||||
@ -67,32 +65,30 @@ class CustomFieldTest(TestCase):
|
|||||||
obj_type = ContentType.objects.get_for_model(Site)
|
obj_type = ContentType.objects.get_for_model(Site)
|
||||||
|
|
||||||
# Create a custom field
|
# Create a custom field
|
||||||
cf = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='my_field', required=False)
|
cf = CustomField(
|
||||||
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
name='my_field',
|
||||||
|
required=False,
|
||||||
|
choices=['Option A', 'Option B', 'Option C']
|
||||||
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.obj_type.set([obj_type])
|
cf.obj_type.set([obj_type])
|
||||||
cf.save()
|
cf.save()
|
||||||
|
|
||||||
# Create some choices for the field
|
|
||||||
CustomFieldChoice.objects.bulk_create([
|
|
||||||
CustomFieldChoice(field=cf, value='Option A'),
|
|
||||||
CustomFieldChoice(field=cf, value='Option B'),
|
|
||||||
CustomFieldChoice(field=cf, value='Option C'),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Assign a value to the first Site
|
# Assign a value to the first Site
|
||||||
site = Site.objects.first()
|
site = Site.objects.first()
|
||||||
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
|
site.custom_field_data[cf.name] = 'Option A'
|
||||||
cfv.value = cf.choices.first()
|
site.save()
|
||||||
cfv.save()
|
|
||||||
|
|
||||||
# Retrieve the stored value
|
# Retrieve the stored value
|
||||||
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
|
site.refresh_from_db()
|
||||||
self.assertEqual(str(cfv.value), 'Option A')
|
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
|
||||||
|
|
||||||
# Delete the stored value
|
# Delete the stored value
|
||||||
cfv.value = None
|
site.custom_field_data.pop(cf.name)
|
||||||
cfv.save()
|
site.save()
|
||||||
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
|
site.refresh_from_db()
|
||||||
|
self.assertIsNone(site.custom_field_data.get(cf.name))
|
||||||
|
|
||||||
# Delete the custom field
|
# Delete the custom field
|
||||||
cf.delete()
|
cf.delete()
|
||||||
@ -143,18 +139,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
cls.cf_url.obj_type.set([content_type])
|
cls.cf_url.obj_type.set([content_type])
|
||||||
|
|
||||||
# Select custom field
|
# Select custom field
|
||||||
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field')
|
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
|
||||||
|
cls.cf_select.default = 'Foo'
|
||||||
cls.cf_select.save()
|
cls.cf_select.save()
|
||||||
cls.cf_select.obj_type.set([content_type])
|
cls.cf_select.obj_type.set([content_type])
|
||||||
cls.cf_select_choice1 = CustomFieldChoice(field=cls.cf_select, value='Foo')
|
|
||||||
cls.cf_select_choice1.save()
|
|
||||||
cls.cf_select_choice2 = CustomFieldChoice(field=cls.cf_select, value='Bar')
|
|
||||||
cls.cf_select_choice2.save()
|
|
||||||
cls.cf_select_choice3 = CustomFieldChoice(field=cls.cf_select, value='Baz')
|
|
||||||
cls.cf_select_choice3.save()
|
|
||||||
|
|
||||||
cls.cf_select.default = cls.cf_select_choice1.value
|
|
||||||
cls.cf_select.save()
|
|
||||||
|
|
||||||
# Create some sites
|
# Create some sites
|
||||||
cls.sites = (
|
cls.sites = (
|
||||||
@ -164,20 +152,17 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
Site.objects.bulk_create(cls.sites)
|
Site.objects.bulk_create(cls.sites)
|
||||||
|
|
||||||
# Assign custom field values for site 2
|
# Assign custom field values for site 2
|
||||||
site2_cfvs = {
|
cls.sites[1].custom_field_data = {
|
||||||
cls.cf_text: 'bar',
|
cls.cf_text.name: 'bar',
|
||||||
cls.cf_integer: 456,
|
cls.cf_integer.name: 456,
|
||||||
cls.cf_boolean: True,
|
cls.cf_boolean.name: True,
|
||||||
cls.cf_date: '2020-01-02',
|
cls.cf_date.name: '2020-01-02',
|
||||||
cls.cf_url: 'http://example.com/2',
|
cls.cf_url.name: 'http://example.com/2',
|
||||||
cls.cf_select: cls.cf_select_choice2.pk,
|
cls.cf_select.name: 'Bar',
|
||||||
}
|
}
|
||||||
for field, value in site2_cfvs.items():
|
cls.sites[1].save()
|
||||||
cfv = CustomFieldValue(field=field, obj=cls.sites[1])
|
|
||||||
cfv.value = value
|
|
||||||
cfv.save()
|
|
||||||
|
|
||||||
def test_get_single_object_without_custom_field_values(self):
|
def test_get_single_object_without_custom_field_data(self):
|
||||||
"""
|
"""
|
||||||
Validate that custom fields are present on an object even if it has no values defined.
|
Validate that custom fields are present on an object even if it has no values defined.
|
||||||
"""
|
"""
|
||||||
@ -195,13 +180,11 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'choice_field': None,
|
'choice_field': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_get_single_object_with_custom_field_values(self):
|
def test_get_single_object_with_custom_field_data(self):
|
||||||
"""
|
"""
|
||||||
Validate that custom fields are present and correctly set for an object with values defined.
|
Validate that custom fields are present and correctly set for an object with values defined.
|
||||||
"""
|
"""
|
||||||
site2_cfvs = {
|
site2_cfvs = self.sites[1].custom_field_data
|
||||||
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
|
|
||||||
}
|
|
||||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||||
self.add_permissions('dcim.view_site')
|
self.add_permissions('dcim.view_site')
|
||||||
|
|
||||||
@ -212,7 +195,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
|
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
|
||||||
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
|
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
|
||||||
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
|
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
|
||||||
self.assertEqual(response.data['custom_fields']['choice_field']['label'], self.cf_select_choice2.value)
|
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
|
||||||
|
|
||||||
def test_create_single_object_with_defaults(self):
|
def test_create_single_object_with_defaults(self):
|
||||||
"""
|
"""
|
||||||
@ -235,19 +218,16 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
||||||
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
||||||
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
||||||
self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
|
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
|
||||||
|
|
||||||
# Validate database data
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
cfvs = {
|
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
|
||||||
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
|
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
|
||||||
}
|
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
|
||||||
self.assertEqual(cfvs['text_field'], self.cf_text.default)
|
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
|
||||||
self.assertEqual(cfvs['number_field'], self.cf_integer.default)
|
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
|
||||||
self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
|
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
|
||||||
self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
|
|
||||||
self.assertEqual(cfvs['url_field'], self.cf_url.default)
|
|
||||||
self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
|
|
||||||
|
|
||||||
def test_create_single_object_with_values(self):
|
def test_create_single_object_with_values(self):
|
||||||
"""
|
"""
|
||||||
@ -262,7 +242,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'boolean_field': True,
|
'boolean_field': True,
|
||||||
'date_field': '2020-01-02',
|
'date_field': '2020-01-02',
|
||||||
'url_field': 'http://example.com/2',
|
'url_field': 'http://example.com/2',
|
||||||
'choice_field': self.cf_select_choice2.pk,
|
'choice_field': 'Bar',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
url = reverse('dcim-api:site-list')
|
url = reverse('dcim-api:site-list')
|
||||||
@ -283,15 +263,12 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
|
|
||||||
# Validate database data
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
cfvs = {
|
self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
|
||||||
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
|
self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field'])
|
||||||
}
|
self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
|
||||||
self.assertEqual(cfvs['text_field'], data_cf['text_field'])
|
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
|
||||||
self.assertEqual(cfvs['number_field'], data_cf['number_field'])
|
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
|
||||||
self.assertEqual(cfvs['boolean_field'], data_cf['boolean_field'])
|
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
|
||||||
self.assertEqual(str(cfvs['date_field']), data_cf['date_field'])
|
|
||||||
self.assertEqual(cfvs['url_field'], data_cf['url_field'])
|
|
||||||
self.assertEqual(cfvs['choice_field'].pk, data_cf['choice_field'])
|
|
||||||
|
|
||||||
def test_create_multiple_objects_with_defaults(self):
|
def test_create_multiple_objects_with_defaults(self):
|
||||||
"""
|
"""
|
||||||
@ -328,19 +305,16 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
||||||
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
||||||
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
||||||
self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
|
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
|
||||||
|
|
||||||
# Validate database data
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data[i]['id'])
|
site = Site.objects.get(pk=response.data[i]['id'])
|
||||||
cfvs = {
|
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
|
||||||
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
|
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
|
||||||
}
|
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
|
||||||
self.assertEqual(cfvs['text_field'], self.cf_text.default)
|
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
|
||||||
self.assertEqual(cfvs['number_field'], self.cf_integer.default)
|
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
|
||||||
self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
|
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
|
||||||
self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
|
|
||||||
self.assertEqual(cfvs['url_field'], self.cf_url.default)
|
|
||||||
self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
|
|
||||||
|
|
||||||
def test_create_multiple_objects_with_values(self):
|
def test_create_multiple_objects_with_values(self):
|
||||||
"""
|
"""
|
||||||
@ -352,7 +326,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'boolean_field': True,
|
'boolean_field': True,
|
||||||
'date_field': '2020-01-02',
|
'date_field': '2020-01-02',
|
||||||
'url_field': 'http://example.com/2',
|
'url_field': 'http://example.com/2',
|
||||||
'choice_field': self.cf_select_choice2.pk,
|
'choice_field': 'Bar',
|
||||||
}
|
}
|
||||||
data = (
|
data = (
|
||||||
{
|
{
|
||||||
@ -391,24 +365,20 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
|
|
||||||
# Validate database data
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data[i]['id'])
|
site = Site.objects.get(pk=response.data[i]['id'])
|
||||||
cfvs = {
|
self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
|
||||||
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
|
self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field'])
|
||||||
}
|
self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
|
||||||
self.assertEqual(cfvs['text_field'], custom_field_data['text_field'])
|
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
|
||||||
self.assertEqual(cfvs['number_field'], custom_field_data['number_field'])
|
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
|
||||||
self.assertEqual(cfvs['boolean_field'], custom_field_data['boolean_field'])
|
self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
|
||||||
self.assertEqual(str(cfvs['date_field']), custom_field_data['date_field'])
|
|
||||||
self.assertEqual(cfvs['url_field'], custom_field_data['url_field'])
|
|
||||||
self.assertEqual(cfvs['choice_field'].pk, custom_field_data['choice_field'])
|
|
||||||
|
|
||||||
def test_update_single_object_with_values(self):
|
def test_update_single_object_with_values(self):
|
||||||
"""
|
"""
|
||||||
Update an object with existing custom field values. Ensure that only the updated custom field values are
|
Update an object with existing custom field values. Ensure that only the updated custom field values are
|
||||||
modified.
|
modified.
|
||||||
"""
|
"""
|
||||||
site2_original_cfvs = {
|
site = self.sites[1]
|
||||||
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
|
original_cfvs = {**site.custom_field_data}
|
||||||
}
|
|
||||||
data = {
|
data = {
|
||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'text_field': 'ABCD',
|
'text_field': 'ABCD',
|
||||||
@ -423,55 +393,21 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
|
|
||||||
# Validate response data
|
# Validate response data
|
||||||
response_cf = response.data['custom_fields']
|
response_cf = response.data['custom_fields']
|
||||||
data_cf = data['custom_fields']
|
self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
|
||||||
self.assertEqual(response_cf['text_field'], data_cf['text_field'])
|
self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field'])
|
||||||
self.assertEqual(response_cf['number_field'], data_cf['number_field'])
|
self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
|
||||||
# TODO: Non-updated fields are missing from the response data
|
self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
|
||||||
# self.assertEqual(response_cf['boolean_field'], site2_original_cfvs['boolean_field'])
|
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
||||||
# self.assertEqual(response_cf['date_field'], site2_original_cfvs['date_field'])
|
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
|
||||||
# self.assertEqual(response_cf['url_field'], site2_original_cfvs['url_field'])
|
|
||||||
# self.assertEqual(response_cf['choice_field']['label'], site2_original_cfvs['choice_field'].value)
|
|
||||||
|
|
||||||
# Validate database data
|
# Validate database data
|
||||||
site2_updated_cfvs = {
|
site.refresh_from_db()
|
||||||
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
|
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field'])
|
||||||
}
|
self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field'])
|
||||||
self.assertEqual(site2_updated_cfvs['text_field'], data_cf['text_field'])
|
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
|
||||||
self.assertEqual(site2_updated_cfvs['number_field'], data_cf['number_field'])
|
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field'])
|
||||||
self.assertEqual(site2_updated_cfvs['boolean_field'], site2_original_cfvs['boolean_field'])
|
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field'])
|
||||||
self.assertEqual(site2_updated_cfvs['date_field'], site2_original_cfvs['date_field'])
|
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
|
||||||
self.assertEqual(site2_updated_cfvs['url_field'], site2_original_cfvs['url_field'])
|
|
||||||
self.assertEqual(site2_updated_cfvs['choice_field'], site2_original_cfvs['choice_field'])
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceAPITest(APITestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
|
|
||||||
|
|
||||||
self.cf_1 = CustomField.objects.create(name="cf_1", type=CustomFieldTypeChoices.TYPE_SELECT)
|
|
||||||
self.cf_2 = CustomField.objects.create(name="cf_2", type=CustomFieldTypeChoices.TYPE_SELECT)
|
|
||||||
|
|
||||||
self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
|
|
||||||
self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
|
|
||||||
self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
|
|
||||||
|
|
||||||
def test_list_cfc(self):
|
|
||||||
url = reverse('extras-api:custom-field-choice-list')
|
|
||||||
response = self.client.get(url, **self.header)
|
|
||||||
|
|
||||||
self.assertEqual(len(response.data), 2)
|
|
||||||
self.assertEqual(len(response.data[self.cf_1.name]), 2)
|
|
||||||
self.assertEqual(len(response.data[self.cf_2.name]), 1)
|
|
||||||
|
|
||||||
self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
|
|
||||||
self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
|
|
||||||
self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
|
|
||||||
|
|
||||||
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
|
|
||||||
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
|
|
||||||
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldImportTest(TestCase):
|
class CustomFieldImportTest(TestCase):
|
||||||
@ -489,18 +425,12 @@ class CustomFieldImportTest(TestCase):
|
|||||||
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
|
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
|
||||||
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
|
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
|
||||||
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
||||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT),
|
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Choice A', 'Choice B', 'Choice C']),
|
||||||
)
|
)
|
||||||
for cf in custom_fields:
|
for cf in custom_fields:
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.obj_type.set([ContentType.objects.get_for_model(Site)])
|
cf.obj_type.set([ContentType.objects.get_for_model(Site)])
|
||||||
|
|
||||||
CustomFieldChoice.objects.bulk_create((
|
|
||||||
CustomFieldChoice(field=custom_fields[5], value='Choice A'),
|
|
||||||
CustomFieldChoice(field=custom_fields[5], value='Choice B'),
|
|
||||||
CustomFieldChoice(field=custom_fields[5], value='Choice C'),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_import(self):
|
def test_import(self):
|
||||||
"""
|
"""
|
||||||
Import a Site in CSV format, including a value for each CustomField.
|
Import a Site in CSV format, including a value for each CustomField.
|
||||||
@ -517,34 +447,28 @@ class CustomFieldImportTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Validate data for site 1
|
# Validate data for site 1
|
||||||
custom_field_values = {
|
site1 = Site.objects.get(name='Site 1')
|
||||||
cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items()
|
self.assertEqual(len(site1.custom_field_data), 6)
|
||||||
}
|
self.assertEqual(site1.custom_field_data['text'], 'ABC')
|
||||||
self.assertEqual(len(custom_field_values), 6)
|
self.assertEqual(site1.custom_field_data['integer'], 123)
|
||||||
self.assertEqual(custom_field_values['text'], 'ABC')
|
self.assertEqual(site1.custom_field_data['boolean'], True)
|
||||||
self.assertEqual(custom_field_values['integer'], 123)
|
self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
|
||||||
self.assertEqual(custom_field_values['boolean'], True)
|
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
|
||||||
self.assertEqual(custom_field_values['date'], date(2020, 1, 1))
|
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
|
||||||
self.assertEqual(custom_field_values['url'], 'http://example.com/1')
|
|
||||||
self.assertEqual(custom_field_values['select'].value, 'Choice A')
|
|
||||||
|
|
||||||
# Validate data for site 2
|
# Validate data for site 2
|
||||||
custom_field_values = {
|
site2 = Site.objects.get(name='Site 2')
|
||||||
cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items()
|
self.assertEqual(len(site2.custom_field_data), 6)
|
||||||
}
|
self.assertEqual(site2.custom_field_data['text'], 'DEF')
|
||||||
self.assertEqual(len(custom_field_values), 6)
|
self.assertEqual(site2.custom_field_data['integer'], 456)
|
||||||
self.assertEqual(custom_field_values['text'], 'DEF')
|
self.assertEqual(site2.custom_field_data['boolean'], False)
|
||||||
self.assertEqual(custom_field_values['integer'], 456)
|
self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
|
||||||
self.assertEqual(custom_field_values['boolean'], False)
|
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
||||||
self.assertEqual(custom_field_values['date'], date(2020, 1, 2))
|
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
|
||||||
self.assertEqual(custom_field_values['url'], 'http://example.com/2')
|
|
||||||
self.assertEqual(custom_field_values['select'].value, 'Choice B')
|
|
||||||
|
|
||||||
# No CustomFieldValues should be created for site 3
|
# No custom field data should be set for site 3
|
||||||
obj_type = ContentType.objects.get_for_model(Site)
|
|
||||||
site3 = Site.objects.get(name='Site 3')
|
site3 = Site.objects.get(name='Site 3')
|
||||||
self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists())
|
self.assertFalse(any(site3.custom_field_data.values()))
|
||||||
self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check
|
|
||||||
|
|
||||||
def test_import_missing_required(self):
|
def test_import_missing_required(self):
|
||||||
"""
|
"""
|
||||||
|
42
netbox/ipam/migrations/0038_custom_field_data.py
Normal file
42
netbox/ipam/migrations/0038_custom_field_data.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ipam', '0037_ipaddress_assignment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='aggregate',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ipaddress',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='prefix',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vlan',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vrf',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -1,6 +1,6 @@
|
|||||||
import netaddr
|
import netaddr
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@ -70,11 +70,6 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -178,11 +173,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -364,11 +354,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = PrefixQuerySet.as_manager()
|
objects = PrefixQuerySet.as_manager()
|
||||||
@ -647,11 +632,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = IPAddressManager()
|
objects = IPAddressManager()
|
||||||
@ -935,11 +915,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -1050,11 +1025,6 @@ class Service(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
@ -483,9 +483,10 @@ REST_FRAMEWORK = {
|
|||||||
SWAGGER_SETTINGS = {
|
SWAGGER_SETTINGS = {
|
||||||
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
|
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
|
||||||
'DEFAULT_FIELD_INSPECTORS': [
|
'DEFAULT_FIELD_INSPECTORS': [
|
||||||
|
'utilities.custom_inspectors.CustomFieldsDataFieldInspector',
|
||||||
'utilities.custom_inspectors.JSONFieldInspector',
|
'utilities.custom_inspectors.JSONFieldInspector',
|
||||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
'utilities.custom_inspectors.ChoiceFieldInspector',
|
||||||
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
|
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
|
||||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||||
|
17
netbox/secrets/migrations/0010_custom_field_data.py
Normal file
17
netbox/secrets/migrations/0010_custom_field_data.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('secrets', '0009_secretrole_drop_users_groups'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='secret',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -6,7 +6,6 @@ from Crypto.Util import strxor
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password, check_password
|
from django.contrib.auth.hashers import make_password, check_password
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -306,11 +305,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
|||||||
max_length=128,
|
max_length=128,
|
||||||
editable=False
|
editable=False
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
17
netbox/tenancy/migrations/0010_custom_field_data.py
Normal file
17
netbox/tenancy/migrations/0010_custom_field_data.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tenancy', '0009_standardize_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -1,4 +1,3 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
@ -102,11 +101,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
@ -5,7 +5,7 @@ from drf_yasg.utils import get_serializer_ref_name
|
|||||||
from rest_framework.fields import ChoiceField
|
from rest_framework.fields import ChoiceField
|
||||||
from rest_framework.relations import ManyRelatedField
|
from rest_framework.relations import ManyRelatedField
|
||||||
|
|
||||||
from extras.api.customfields import CustomFieldsSerializer
|
from extras.api.customfields import CustomFieldsDataField
|
||||||
from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
|
from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
|
|||||||
return NotHandled
|
return NotHandled
|
||||||
|
|
||||||
|
|
||||||
class CustomChoiceFieldInspector(FieldInspector):
|
class ChoiceFieldInspector(FieldInspector):
|
||||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||||
# this returns a callable which extracts title, description and other stuff
|
# this returns a callable which extracts title, description and other stuff
|
||||||
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
|
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
|
||||||
@ -83,10 +83,6 @@ class CustomChoiceFieldInspector(FieldInspector):
|
|||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
elif isinstance(field, CustomFieldsSerializer):
|
|
||||||
schema = SwaggerType(type=openapi.TYPE_OBJECT)
|
|
||||||
return schema
|
|
||||||
|
|
||||||
return NotHandled
|
return NotHandled
|
||||||
|
|
||||||
|
|
||||||
@ -102,6 +98,17 @@ class NullableBooleanFieldInspector(FieldInspector):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldsDataFieldInspector(FieldInspector):
|
||||||
|
|
||||||
|
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||||
|
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||||
|
|
||||||
|
if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema:
|
||||||
|
return SwaggerType(type=openapi.TYPE_OBJECT)
|
||||||
|
|
||||||
|
return NotHandled
|
||||||
|
|
||||||
|
|
||||||
class JSONFieldInspector(FieldInspector):
|
class JSONFieldInspector(FieldInspector):
|
||||||
"""Required because by default, Swagger sees a JSONField as a string and not dict
|
"""Required because by default, Swagger sees a JSONField as a string and not dict
|
||||||
"""
|
"""
|
||||||
|
@ -91,11 +91,9 @@ def serialize_object(obj, extra=None, exclude=None):
|
|||||||
json_str = serialize('json', [obj])
|
json_str = serialize('json', [obj])
|
||||||
data = json.loads(json_str)[0]['fields']
|
data = json.loads(json_str)[0]['fields']
|
||||||
|
|
||||||
# Include any custom fields
|
# Include custom_field_data as "custom_fields"
|
||||||
if hasattr(obj, 'get_custom_fields'):
|
if hasattr(obj, 'custom_field_data'):
|
||||||
data['custom_fields'] = {
|
data['custom_fields'] = data.pop('custom_field_data')
|
||||||
field: str(value) for field, value in obj.cf.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
|
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
|
||||||
if is_taggable(obj):
|
if is_taggable(obj):
|
||||||
|
@ -28,8 +28,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME
|
|||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from django_tables2 import RequestConfig
|
from django_tables2 import RequestConfig
|
||||||
|
|
||||||
from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
from extras.models import CustomField, ExportTemplate
|
||||||
from extras.querysets import CustomFieldQueryset
|
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
|
from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
|
||||||
from utilities.permissions import get_permission_for_model, resolve_permission
|
from utilities.permissions import get_permission_for_model, resolve_permission
|
||||||
@ -231,8 +230,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
headers = self.queryset.model.csv_headers.copy()
|
headers = self.queryset.model.csv_headers.copy()
|
||||||
|
|
||||||
# Add custom field headers, if any
|
# Add custom field headers, if any
|
||||||
if hasattr(self.queryset.model, 'get_custom_fields'):
|
if hasattr(self.queryset.model, 'custom_field_data'):
|
||||||
for custom_field in self.queryset.model().get_custom_fields():
|
for custom_field in CustomField.objects.get_for_model(self.queryset.model):
|
||||||
headers.append(custom_field.name)
|
headers.append(custom_field.name)
|
||||||
custom_fields.append(custom_field.name)
|
custom_fields.append(custom_field.name)
|
||||||
|
|
||||||
@ -257,19 +256,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
if self.filterset:
|
if self.filterset:
|
||||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||||
|
|
||||||
# If this type of object has one or more custom fields, prefetch any relevant custom field values
|
|
||||||
custom_fields = CustomField.objects.filter(
|
|
||||||
obj_type=ContentType.objects.get_for_model(model)
|
|
||||||
).prefetch_related('choices')
|
|
||||||
if custom_fields:
|
|
||||||
self.queryset = self.queryset.prefetch_related('custom_field_values')
|
|
||||||
|
|
||||||
# Check for export template rendering
|
# Check for export template rendering
|
||||||
if request.GET.get('export'):
|
if request.GET.get('export'):
|
||||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
|
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
|
||||||
queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
|
|
||||||
try:
|
try:
|
||||||
return et.render_to_response(queryset)
|
return et.render_to_response(self.queryset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
@ -951,38 +942,18 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|||||||
elif form.cleaned_data[name] not in (None, ''):
|
elif form.cleaned_data[name] not in (None, ''):
|
||||||
setattr(obj, name, form.cleaned_data[name])
|
setattr(obj, name, form.cleaned_data[name])
|
||||||
|
|
||||||
# Cache custom fields on instance prior to save()
|
# Update custom fields
|
||||||
if custom_fields:
|
for name in custom_fields:
|
||||||
obj._cf = {
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
name: form.cleaned_data[name] for name in custom_fields
|
obj.custom_field_data.pop(name, None)
|
||||||
}
|
else:
|
||||||
|
obj.custom_field_data[name] = form.cleaned_data[name]
|
||||||
|
|
||||||
obj.full_clean()
|
obj.full_clean()
|
||||||
obj.save()
|
obj.save()
|
||||||
updated_objects.append(obj)
|
updated_objects.append(obj)
|
||||||
logger.debug(f"Saved {obj} (PK: {obj.pk})")
|
logger.debug(f"Saved {obj} (PK: {obj.pk})")
|
||||||
|
|
||||||
# Update custom fields
|
|
||||||
obj_type = ContentType.objects.get_for_model(model)
|
|
||||||
for name in custom_fields:
|
|
||||||
field = form.fields[name].model
|
|
||||||
if name in form.nullable_fields and name in nullified_fields:
|
|
||||||
CustomFieldValue.objects.filter(
|
|
||||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
|
||||||
).delete()
|
|
||||||
elif form.cleaned_data[name] not in [None, '']:
|
|
||||||
try:
|
|
||||||
cfv = CustomFieldValue.objects.get(
|
|
||||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
|
||||||
)
|
|
||||||
except CustomFieldValue.DoesNotExist:
|
|
||||||
cfv = CustomFieldValue(
|
|
||||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
|
||||||
)
|
|
||||||
cfv.value = form.cleaned_data[name]
|
|
||||||
cfv.save()
|
|
||||||
logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})")
|
|
||||||
|
|
||||||
# Add/remove tags
|
# Add/remove tags
|
||||||
if form.cleaned_data.get('add_tags', None):
|
if form.cleaned_data.get('add_tags', None):
|
||||||
obj.tags.add(*form.cleaned_data['add_tags'])
|
obj.tags.add(*form.cleaned_data['add_tags'])
|
||||||
|
22
netbox/virtualization/migrations/0018_custom_field_data.py
Normal file
22
netbox/virtualization/migrations/0018_custom_field_data.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('virtualization', '0017_update_jsonfield'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cluster',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='virtualmachine',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -150,11 +150,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
@ -275,11 +270,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
|||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
custom_field_values = GenericRelation(
|
|
||||||
to='extras.CustomFieldValue',
|
|
||||||
content_type_field='obj_type',
|
|
||||||
object_id_field='obj_id'
|
|
||||||
)
|
|
||||||
tags = TaggableManager(through=TaggedItem)
|
tags = TaggableManager(through=TaggedItem)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
Loading…
Reference in New Issue
Block a user