diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index c573603c6..fa563881c 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -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', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 1ef1ddd40..3f104ef18 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -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( diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 762bfb0d9..5635f401b 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -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') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 268a5d7c0..8476ea862 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 917aedca5..da62b3d72 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index e06f4dece..6bf78bf6a 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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. diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d36ab28ad..54bf8103c 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -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: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 504de0953..039d6fc44 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -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." }) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index ec7b18839..bcc95467a 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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'] diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index efdcced1d..176d77a88 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -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', diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 003859b2f..0cf4c2045 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -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', diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index af5ee0b2c..d61081de4 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -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', diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 886b5484b..af53afad5 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -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(