Merge pull request #5372 from netbox-community/5274-custom-fields-api

Closes #5274: Enable REST API support for custom fields
This commit is contained in:
Jeremy Stretch 2020-11-23 16:23:19 -05:00 committed by GitHub
commit 8e0a6479ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 134 additions and 38 deletions

View File

@ -3,7 +3,7 @@ from django.db.models import Q
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
@ -19,7 +19,7 @@ __all__ = (
)
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -73,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -2,7 +2,7 @@ import django_filters
from django.contrib.auth.models import User
from django.db.models import Count
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
@ -80,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -179,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -325,7 +325,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -504,7 +504,7 @@ class DeviceFilterSet(
BaseFilterSet,
TenancyFilterSet,
LocalConfigContextFilterSet,
CustomFieldFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(
@ -1246,7 +1246,7 @@ class PowerFeedFilterSet(
BaseFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet,
CustomFieldFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(

View File

@ -6,6 +6,7 @@ from users.api.nested_serializers import NestedUserSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedCustomFieldSerializer',
'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer',
'NestedJobResultSerializer',
@ -13,6 +14,14 @@ __all__ = [
]
class NestedCustomFieldSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
class Meta:
model = models.CustomField
fields = ['id', 'url', 'name']
class NestedConfigContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')

View File

@ -10,7 +10,7 @@ from dcim.api.nested_serializers import (
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.choices import *
from extras.models import (
ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
)
from extras.utils import FeatureQuery
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
@ -24,6 +24,27 @@ from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import *
#
# Custom fields
#
class CustomFieldSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
many=True
)
type = ChoiceField(choices=CustomFieldTypeChoices)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
class Meta:
model = CustomField
fields = [
'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
]
#
# Export templates
#

View File

@ -5,6 +5,9 @@ from . import views
router = OrderedDefaultRouter()
router.APIRootView = views.ExtrasRootView
# Custom fields
router.register('custom-fields', views.CustomFieldViewSet)
# Export templates
router.register('export-templates', views.ExportTemplateViewSet)

View File

@ -12,17 +12,26 @@ from rq import Worker
from extras import filters
from extras.choices import JobResultStatusChoices
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag
from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from utilities.exceptions import RQWorkerNotRunningException
from utilities.querysets import RestrictedQuerySet
from utilities.utils import copy_safe_request
from . import serializers
class ExtrasRootView(APIRootView):
"""
Extras API root view
"""
def get_view_name(self):
return 'Extras'
class ConfigContextQuerySetMixin:
"""
Used by views that work with config context models (device and virtual machine).
@ -46,18 +55,17 @@ class ConfigContextQuerySetMixin:
return self.queryset.annotate_config_context_data()
class ExtrasRootView(APIRootView):
"""
Extras API root view
"""
def get_view_name(self):
return 'Extras'
#
# Custom fields
#
class CustomFieldViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
serializer_class = serializers.CustomFieldSerializer
filterset_class = filters.CustomFieldFilterSet
class CustomFieldModelViewSet(ModelViewSet):
"""
Include the applicable set of CustomFields in the ModelViewSet context.

View File

@ -16,7 +16,7 @@ __all__ = (
'ContentTypeFilterSet',
'CreatedUpdatedFilterSet',
'CustomFieldFilter',
'CustomFieldFilterSet',
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'LocalConfigContextFilterSet',
@ -58,7 +58,7 @@ class CustomFieldFilter(django_filters.Filter):
return queryset.filter(**kwargs)
class CustomFieldFilterSet(django_filters.FilterSet):
class CustomFieldModelFilterSet(django_filters.FilterSet):
"""
Dynamically add a Filter for each CustomField applicable to the parent model.
"""
@ -74,6 +74,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
class CustomFieldFilterSet(django_filters.FilterSet):
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'default', 'weight']
class ExportTemplateFilterSet(BaseFilterSet):
class Meta:

View File

@ -13,6 +13,7 @@ from django.utils.safestring import mark_safe
from extras.choices import *
from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -63,7 +64,7 @@ class CustomFieldModel(models.Model):
raise ValidationError(f"Missing required custom field '{cf.name}'.")
class CustomFieldManager(models.Manager):
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
use_in_migrations = True
def get_for_model(self, model):
@ -193,7 +194,7 @@ class CustomField(models.Model):
})
# A selection field must have at least two choices defined
if self.type == CustomFieldTypeChoices.TYPE_SELECT and len(self.choices) < 2:
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
raise ValidationError({
'choices': "Selection fields must specify at least two choices."
})

View File

@ -11,7 +11,7 @@ from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag
from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag
from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
@ -30,6 +30,53 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class CustomFieldTest(APIViewTestCases.APIViewTestCase):
model = CustomField
brief_fields = ['id', 'name', 'url']
create_data = [
{
'content_types': ['dcim.site'],
'name': 'cf4',
'type': 'date',
},
{
'content_types': ['dcim.site'],
'name': 'cf5',
'type': 'url',
},
{
'content_types': ['dcim.site'],
'name': 'cf6',
'type': 'select',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
custom_fields = (
CustomField(
name='cf1',
type='text'
),
CustomField(
name='cf2',
type='integer'
),
CustomField(
name='cf3',
type='boolean'
),
)
CustomField.objects.bulk_create(custom_fields)
for cf in custom_fields:
cf.content_types.add(site_ct)
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate
brief_fields = ['id', 'name', 'url']

View File

@ -5,7 +5,7 @@ from django.db.models import Q
from netaddr.core import AddrFormatError
from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter,
@ -30,7 +30,7 @@ __all__ = (
)
class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -73,7 +73,7 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create
fields = ['id', 'name', 'rd', 'enforce_unique']
class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -122,7 +122,7 @@ class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'is_private', 'description']
class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -183,7 +183,7 @@ class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -355,7 +355,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre
)
class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -551,7 +551,7 @@ class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
from virtualization.models import VirtualMachine
from .models import Secret, SecretRole
@ -21,7 +21,7 @@ class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class SecretFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
from .models import Tenant, TenantGroup
@ -30,7 +30,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class TenantFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter,
@ -34,7 +34,7 @@ class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -101,7 +101,7 @@ class VirtualMachineFilterSet(
BaseFilterSet,
LocalConfigContextFilterSet,
TenancyFilterSet,
CustomFieldFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(