Merge pull request #5145 from netbox-community/4878-custom-fields

4878 custom fields
This commit is contained in:
Jeremy Stretch 2020-09-17 13:19:05 -04:00 committed by GitHub
commit dbfb9b2cee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 578 additions and 750 deletions

View File

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

View 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),
),
]

View File

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

View 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),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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()])

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
),
),
]

View 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
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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),
),
]

View File

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

View File

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

View 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),
),
]

View File

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

View 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),
),
]

View File

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

View File

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

View File

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

View File

@ -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'])

View 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),
),
]

View File

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