diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index f9fc82e18..3a2391028 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -1,5 +1,32 @@ This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value. +# Custom Fields + +Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. + +Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: + +* Free-form text (up to 255 characters) +* Integer +* Boolean (true/false) +* Date +* URL +* Selection + +Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. + +Marking the field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) + +When creating a selection field, you must create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. + +## Using Custom Fields + +When a single object is edited, the form will include any custom fields which have been defined for its type. These fields are included in the "Custom Fields" panel. Each custom field value must be saved independently from the core object, so it's best to avoid adding too many custom fields per object. + +When editing multiple objects, values are saved in bulk per field. That is, there is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. + # Export Templates NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ed6033963..d7f32f958 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from circuits.models import Provider, CircuitType, Circuit from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -9,11 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer # Providers # -class ProviderSerializer(serializers.ModelSerializer): +class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'custom_fields'] class ProviderNestedSerializer(ProviderSerializer): @@ -43,7 +45,7 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer): # Circuits # -class CircuitSerializer(serializers.ModelSerializer): +class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): provider = ProviderNestedSerializer() type = CircuitTypeNestedSerializer() tenant = TenantNestedSerializer() @@ -53,7 +55,7 @@ class CircuitSerializer(serializers.ModelSerializer): class Meta: model = Circuit fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', - 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments'] + 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields'] class CircuitNestedSerializer(CircuitSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 74cc6656d..866f9283b 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,22 +3,23 @@ from rest_framework import generics from circuits.models import Provider, CircuitType, Circuit from circuits.filters import CircuitFilter +from extras.api.views import CustomFieldModelAPIView from . import serializers -class ProviderListView(generics.ListAPIView): +class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all providers """ - queryset = Provider.objects.all() + queryset = Provider.objects.prefetch_related('custom_field_values__field') serializer_class = serializers.ProviderSerializer -class ProviderDetailView(generics.RetrieveAPIView): +class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single provider """ - queryset = Provider.objects.all() + queryset = Provider.objects.prefetch_related('custom_field_values__field') serializer_class = serializers.ProviderSerializer @@ -38,18 +39,20 @@ class CircuitTypeDetailView(generics.RetrieveAPIView): serializer_class = serializers.CircuitTypeSerializer -class CircuitListView(generics.ListAPIView): +class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): """ List circuits (filterable) """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device') + queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter -class CircuitDetailView(generics.RetrieveAPIView): +class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single circuit """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device') + queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index e3faa6332..f719290ed 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -3,11 +3,12 @@ import django_filters from django.db.models import Q from dcim.models import Site +from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from .models import Provider, Circuit, CircuitType -class ProviderFilter(django_filters.FilterSet): +class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -36,7 +37,7 @@ class ProviderFilter(django_filters.FilterSet): ) -class CircuitFilter(django_filters.FilterSet): +class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index a44dd763e..0cb7a3016 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,6 +2,7 @@ from django import forms from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( @@ -15,7 +16,7 @@ from .models import Circuit, CircuitType, Provider # Providers # -class ProviderForm(forms.ModelForm, BootstrapMixin): +class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() @@ -46,7 +47,7 @@ class ProviderImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=ProviderFromCSVForm) -class ProviderBulkEditForm(forms.Form, BootstrapMixin): +class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) asn = forms.IntegerField(required=False, label='ASN') account = forms.CharField(max_length=30, required=False, label='Account number') @@ -61,7 +62,8 @@ def provider_site_choices(): return [(s.slug, s.name) for s in site_choices] -class ProviderFilterForm(forms.Form, BootstrapMixin): +class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Provider site = forms.MultipleChoiceField(required=False, choices=provider_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -82,7 +84,7 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin): # Circuits # -class CircuitForm(forms.ModelForm, BootstrapMixin): +class CircuitForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', @@ -177,7 +179,7 @@ class CircuitImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=CircuitFromCSVForm) -class CircuitBulkEditForm(forms.Form, BootstrapMixin): +class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) @@ -207,7 +209,8 @@ def circuit_site_choices(): return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] -class CircuitFilterForm(forms.Form, BootstrapMixin): +class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Circuit type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 00367a27a..fd4fdf634 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,13 +1,15 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.urlresolvers import reverse from django.db import models from dcim.fields import ASNField from dcim.models import Site, Interface +from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -class Provider(CreatedUpdatedModel): +class Provider(CreatedUpdatedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -20,6 +22,7 @@ class Provider(CreatedUpdatedModel): noc_contact = models.TextField(blank=True, verbose_name='NOC contact') admin_contact = models.TextField(blank=True, verbose_name='Admin contact') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['name'] @@ -58,7 +61,7 @@ class CircuitType(models.Model): return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) -class Circuit(CreatedUpdatedModel): +class Circuit(CreatedUpdatedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device @@ -78,6 +81,7 @@ class Circuit(CreatedUpdatedModel): xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['provider', 'cid'] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index d84e23525..28c4d6844 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -66,15 +66,6 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'circuits/provider_bulk_edit.html' default_redirect_url = 'circuits:provider_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' @@ -159,19 +150,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'circuits/circuit_bulk_edit.html' default_redirect_url = 'circuits:circuit_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 706bf4a52..583f63d8b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,6 +6,7 @@ from dcim.models import ( DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, ) +from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -13,13 +14,13 @@ from tenancy.api.serializers import TenantNestedSerializer # Sites # -class SiteSerializer(serializers.ModelSerializer): +class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: model = Site fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', - 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] class SiteNestedSerializer(SiteSerializer): @@ -68,7 +69,7 @@ class RackRoleNestedSerializer(RackRoleSerializer): # -class RackSerializer(serializers.ModelSerializer): +class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = RackGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -77,7 +78,7 @@ class RackSerializer(serializers.ModelSerializer): class Meta: model = Rack fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments'] + 'u_height', 'comments', 'custom_fields'] class RackNestedSerializer(RackSerializer): @@ -92,7 +93,7 @@ class RackDetailSerializer(RackSerializer): class Meta(RackSerializer.Meta): fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments', 'front_units', 'rear_units'] + 'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) @@ -237,7 +238,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer): fields = ['id', 'family', 'address'] -class DeviceSerializer(serializers.ModelSerializer): +class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): device_type = DeviceTypeNestedSerializer() device_role = DeviceRoleNestedSerializer() tenant = TenantNestedSerializer() @@ -252,7 +253,7 @@ class DeviceSerializer(serializers.ModelSerializer): model = Device fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'comments'] + 'primary_ip6', 'comments', 'custom_fields'] def get_parent_device(self, obj): try: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5bde03a6e..01a8c6f61 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -5,6 +5,7 @@ from rest_framework.settings import api_settings from rest_framework.views import APIView from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -13,29 +14,30 @@ from dcim.models import ( InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters -from .exceptions import MissingFilterException -from . import serializers +from extras.api.views import CustomFieldModelAPIView from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from utilities.api import ServiceUnavailable +from .exceptions import MissingFilterException +from . import serializers # # Sites # -class SiteListView(generics.ListAPIView): +class SiteListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all sites """ - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.SiteSerializer -class SiteDetailView(generics.RetrieveAPIView): +class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single site """ - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.SiteSerializer @@ -84,20 +86,22 @@ class RackRoleDetailView(generics.RetrieveAPIView): # Racks # -class RackListView(generics.ListAPIView): +class RackListView(CustomFieldModelAPIView, generics.ListAPIView): """ List racks (filterable) """ - queryset = Rack.objects.select_related('site', 'group', 'tenant') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.RackSerializer filter_class = filters.RackFilter -class RackDetailView(generics.RetrieveAPIView): +class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single rack """ - queryset = Rack.objects.select_related('site', 'group', 'tenant') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.RackDetailSerializer @@ -209,24 +213,25 @@ class PlatformDetailView(generics.RetrieveAPIView): # Devices # -class DeviceListView(generics.ListAPIView): +class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): """ List devices (filterable) """ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside', - 'primary_ip6__nat_outside') + 'primary_ip6__nat_outside', + 'custom_field_values__field') serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] -class DeviceDetailView(generics.RetrieveAPIView): +class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single device """ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', - 'rack__site', 'parent_bay') + 'rack__site', 'parent_bay').prefetch_related('custom_field_values__field') serializer_class = serializers.DeviceSerializer @@ -426,6 +431,13 @@ class RelatedConnectionsView(APIView): Retrieve all connections related to a given console/power/interface connection """ + def __init__(self): + super(RelatedConnectionsView, self).__init__() + + # Custom fields + self.content_type = ContentType.objects.get_for_model(Device) + self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + def get(self, request): peer_device = request.GET.get('peer-device') @@ -450,7 +462,7 @@ class RelatedConnectionsView(APIView): # Initialize response skeleton response = { - 'device': serializers.DeviceSerializer(device).data, + 'device': serializers.DeviceSerializer(device, context={'view': self}).data, 'console-ports': [], 'power-ports': [], 'interfaces': [], diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 3033ce564..eac47445e 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -2,14 +2,15 @@ import django_filters from django.db.models import Q +from extras.filters import CustomFieldFilterSet +from tenancy.models import Tenant from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) -from tenancy.models import Tenant -class SiteFilter(django_filters.FilterSet): +class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -58,7 +59,7 @@ class RackGroupFilter(django_filters.FilterSet): fields = ['site_id', 'site'] -class RackFilter(django_filters.FilterSet): +class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -139,7 +140,7 @@ class DeviceTypeFilter(django_filters.FilterSet): 'is_network_device'] -class DeviceFilter(django_filters.FilterSet): +class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5d579c709..8d2e48430 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3,6 +3,7 @@ import re from django import forms from django.db.models import Count, Q +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant @@ -78,7 +79,7 @@ def bulkedit_rackrole_choices(): # Sites # -class SiteForm(forms.ModelForm, BootstrapMixin): +class SiteForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() @@ -111,7 +112,7 @@ class SiteImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=SiteFromCSVForm) -class SiteBulkEditForm(forms.Form, BootstrapMixin): +class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') @@ -121,7 +122,8 @@ def site_tenant_choices(): return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices] -class SiteFilterForm(forms.Form, BootstrapMixin): +class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Site tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -164,7 +166,7 @@ class RackRoleForm(forms.ModelForm, BootstrapMixin): # Racks # -class RackForm(forms.ModelForm, BootstrapMixin): +class RackForm(BootstrapMixin, CustomFieldForm): group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( api_url='/api/dcim/rack-groups/?site_id={{site}}', )) @@ -240,7 +242,7 @@ class RackImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=RackFromCSVForm) -class RackBulkEditForm(forms.Form, BootstrapMixin): +class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') @@ -272,7 +274,8 @@ def rack_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] -class RackFilterForm(forms.Form, BootstrapMixin): +class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Rack site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', @@ -404,7 +407,7 @@ class PlatformForm(forms.ModelForm, BootstrapMixin): # Devices # -class DeviceForm(forms.ModelForm, BootstrapMixin): +class DeviceForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', @@ -613,7 +616,7 @@ class ChildDeviceImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) -class DeviceBulkEditForm(forms.Form, BootstrapMixin): +class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') @@ -654,7 +657,8 @@ def device_platform_choices(): return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices] -class DeviceFilterForm(forms.Form, BootstrapMixin): +class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Device site = forms.MultipleChoiceField(required=False, choices=device_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a0e2b4e0f..b8185881f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,12 +1,15 @@ from collections import OrderedDict from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import MultipleObjectsReturned, ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, ObjectDoesNotExist +from extras.models import CustomFieldModel, CustomField, CustomFieldValue from extras.rpc import RPC_CLIENTS from tenancy.models import Tenant from utilities.fields import NullableCharField @@ -213,7 +216,7 @@ class SiteManager(NaturalOrderByManager): return self.natural_order_by('name') -class Site(CreatedUpdatedModel): +class Site(CreatedUpdatedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -226,6 +229,7 @@ class Site(CreatedUpdatedModel): physical_address = models.CharField(max_length=200, blank=True) shipping_address = models.CharField(max_length=200, blank=True) comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = SiteManager() @@ -320,7 +324,7 @@ class RackManager(NaturalOrderByManager): return self.natural_order_by('site__name', 'name') -class Rack(CreatedUpdatedModel): +class Rack(CreatedUpdatedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -337,6 +341,7 @@ class Rack(CreatedUpdatedModel): u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)]) comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = RackManager() @@ -719,7 +724,7 @@ class DeviceManager(NaturalOrderByManager): return self.natural_order_by('name') -class Device(CreatedUpdatedModel): +class Device(CreatedUpdatedModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -750,6 +755,7 @@ class Device(CreatedUpdatedModel): primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IPv6') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = DeviceManager() diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index b8ddf9421..f7f63c555 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -21,6 +21,7 @@ class SiteTest(APITestCase): 'physical_address', 'shipping_address', 'comments', + 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', @@ -46,7 +47,8 @@ class SiteTest(APITestCase): 'type', 'width', 'u_height', - 'comments' + 'comments', + 'custom_fields', ] graph_fields = [ @@ -125,7 +127,8 @@ class RackTest(APITestCase): 'type', 'width', 'u_height', - 'comments' + 'comments', + 'custom_fields', ] detail_fields = [ @@ -141,6 +144,7 @@ class RackTest(APITestCase): 'width', 'u_height', 'comments', + 'custom_fields', 'front_units', 'rear_units' ] @@ -337,6 +341,7 @@ class DeviceTest(APITestCase): 'primary_ip4', 'primary_ip6', 'comments', + 'custom_fields', ] nested_fields = ['id', 'name', 'display_name'] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 17c0e1886..f3b2f4bf1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -122,16 +122,6 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/site_bulk_edit.html' default_redirect_url = 'dcim:site_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - # # Rack groups @@ -248,20 +238,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/rack_bulk_edit.html' default_redirect_url = 'dcim:rack_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['group', 'tenant', 'role']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['site', 'type', 'width', 'u_height', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' @@ -372,15 +348,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/devicetype_bulk_edit.html' default_redirect_url = 'dcim:devicetype_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['manufacturer', 'u_height']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' @@ -682,23 +649,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/device_bulk_edit.html' default_redirect_url = 'dcim:device_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['tenant', 'platform']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - if form.cleaned_data['status']: - status = form.cleaned_data['status'] - fields_to_update['status'] = True if status == 'True' else False - for field in ['tenant', 'device_type', 'device_role', 'serial']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f7ddbbae2..402cd6093 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,6 +1,36 @@ +from django import forms from django.contrib import admin -from .models import Graph, ExportTemplate, TopologyMap, UserAction +from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction + + +class CustomFieldForm(forms.ModelForm): + + class Meta: + model = CustomField + exclude = [] + + def __init__(self, *args, **kwargs): + super(CustomFieldForm, self).__init__(*args, **kwargs) + + # Organize the available ContentTypes + queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model') + self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] + + +class CustomFieldChoiceAdmin(admin.TabularInline): + model = CustomFieldChoice + extra = 5 + + +@admin.register(CustomField) +class CustomFieldAdmin(admin.ModelAdmin): + inlines = [CustomFieldChoiceAdmin] + list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description'] + form = CustomFieldForm + + def models(self, obj): + return ', '.join([ct.name for ct in obj.obj_type.all()]) @admin.register(Graph) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e0f24cfe7..4e82b4027 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,6 +1,44 @@ from rest_framework import serializers -from extras.models import Graph +from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph + + +class CustomFieldSerializer(serializers.Serializer): + """ + Extends a ModelSerializer to render any CustomFields and their values associated with an object. + """ + custom_fields = serializers.SerializerMethodField() + + def get_custom_fields(self, obj): + + # Gather all CustomFields applicable to this object + fields = {cf.name: None for cf in self.context['view'].custom_fields} + + # Attach any defined CustomFieldValues to their respective CustomFields + for cfv in obj.custom_field_values.all(): + + # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view + # context. + if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'): + cfc = { + 'id': int(cfv.serialized_value), + 'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)] + } + fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data + # Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView. + elif cfv.field.type == CF_TYPE_SELECT: + fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data + else: + fields[cfv.field.name] = cfv.value + + return fields + + +class CustomFieldChoiceSerializer(serializers.ModelSerializer): + + class Meta: + model = CustomFieldChoice + fields = ['id', 'value'] class GraphSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 81c4e9170..b5928dae1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,9 +1,8 @@ import graphviz from rest_framework import generics from rest_framework.views import APIView -import tempfile -from wsgiref.util import FileWrapper +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 @@ -15,6 +14,24 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_P from .serializers import GraphSerializer +class CustomFieldModelAPIView(object): + """ + Include the applicable set of CustomField in the view context. + """ + + def __init__(self): + super(CustomFieldModelAPIView, self).__init__() + self.content_type = ContentType.objects.get_for_model(self.queryset.model) + self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + + # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. + custom_field_choices = {} + for field in self.custom_fields: + for cfc in field.choices.all(): + custom_field_choices[cfc.id] = cfc.value + self.custom_field_choices = custom_field_choices + + class GraphListView(generics.ListAPIView): """ Returns a list of relevant graphs diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py new file mode 100644 index 000000000..d8ccbf986 --- /dev/null +++ b/netbox/extras/filters.py @@ -0,0 +1,33 @@ +import django_filters + +from django.contrib.contenttypes.models import ContentType + +from .models import CustomField + + +class CustomFieldFilter(django_filters.Filter): + """ + Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. + """ + + def filter(self, queryset, value): + if not value.strip(): + return queryset + return queryset.filter( + custom_field_values__field__name=self.name, + custom_field_values__serialized_value=value, + ) + + +class CustomFieldFilterSet(django_filters.FilterSet): + """ + Dynamically add a Filter for each CustomField applicable to the parent model. + """ + + def __init__(self, *args, **kwargs): + super(CustomFieldFilterSet, self).__init__(*args, **kwargs) + + obj_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) + for cf in custom_fields: + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py new file mode 100644 index 000000000..bb41d59a2 --- /dev/null +++ b/netbox/extras/forms.py @@ -0,0 +1,160 @@ +from collections import OrderedDict + +from django import forms +from django.contrib.contenttypes.models import ContentType + +from .models import ( + CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue +) + + +def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): + """ + Retrieve all CustomFields applicable to the given ContentType + """ + field_dict = OrderedDict() + kwargs = {'obj_type': content_type} + if filterable_only: + kwargs['is_filterable'] = True + custom_fields = CustomField.objects.filter(**kwargs) + + for cf in custom_fields: + field_name = 'cf_{}'.format(str(cf.name)) + + # Integer + if cf.type == CF_TYPE_INTEGER: + field = forms.IntegerField(required=cf.required, initial=cf.default) + + # Boolean + elif cf.type == CF_TYPE_BOOLEAN: + choices = ( + (None, '---------'), + (True, 'True'), + (False, 'False'), + ) + if cf.default.lower() in ['true', 'yes', '1']: + initial = True + elif cf.default.lower() in ['false', 'no', '0']: + initial = False + else: + initial = None + field = forms.NullBooleanField(required=cf.required, initial=initial, + widget=forms.Select(choices=choices)) + + # Date + elif cf.type == CF_TYPE_DATE: + field = forms.DateField(required=cf.required, initial=cf.default) + + # Select + elif cf.type == CF_TYPE_SELECT: + if bulk_edit: + choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] + if not cf.required: + choices = [(0, 'None')] + choices + choices = [(None, '---------')] + choices + field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) + else: + field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) + + # URL + elif cf.type == CF_TYPE_URL: + field = forms.URLField(required=cf.required, initial=cf.default) + + # Text + else: + field = forms.CharField(max_length=255, required=cf.required, initial=cf.default) + + field.model = cf + field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() + field.help_text = cf.description + + field_dict[field_name] = field + + return field_dict + + +class CustomFieldForm(forms.ModelForm): + custom_fields = [] + + def __init__(self, *args, **kwargs): + + self.obj_type = ContentType.objects.get_for_model(self._meta.model) + + super(CustomFieldForm, self).__init__(*args, **kwargs) + + # Add all applicable CustomFields to the form + custom_fields = [] + for name, field in get_custom_fields_for_model(self.obj_type).items(): + self.fields[name] = field + custom_fields.append(name) + self.custom_fields = custom_fields + + # If editing an existing object, initialize values for all custom fields + if self.instance.pk: + existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\ + .select_related('field') + for cfv in existing_values: + self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value + + def _save_custom_fields(self): + + for field_name in self.custom_fields: + try: + cfv = CustomFieldValue.objects.select_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, u'']: + 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): + obj = super(CustomFieldForm, self).save(commit) + + # Handle custom fields the same way we do M2M fields + if commit: + self._save_custom_fields() + else: + self.save_custom_fields = self._save_custom_fields + + return obj + + +class CustomFieldBulkEditForm(forms.Form): + custom_fields = [] + + def __init__(self, model, *args, **kwargs): + + self.obj_type = ContentType.objects.get_for_model(model) + + super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs) + + # Add all applicable CustomFields to the form + custom_fields = [] + for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items(): + field.required = False + self.fields[name] = field + custom_fields.append(name) + self.custom_fields = custom_fields + + +class CustomFieldFilterForm(forms.Form): + + def __init__(self, *args, **kwargs): + + self.obj_type = ContentType.objects.get_for_model(self.model) + + super(CustomFieldFilterForm, self).__init__(*args, **kwargs) + + # Add all applicable CustomFields to the form + custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() + for name, field in custom_fields: + field.required = False + self.fields[name] = field diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py new file mode 100644 index 000000000..1d33ca281 --- /dev/null +++ b/netbox/extras/migrations/0002_custom_fields.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-08-23 20:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, b'Text'), (200, b'Integer'), (300, b'Boolean (true/false)'), (400, b'Date'), (500, b'URL'), (600, b'Selection')], default=100)), + ('name', models.CharField(max_length=50, unique=True)), + ('label', models.CharField(blank=True, help_text=b"Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)), + ('description', models.CharField(blank=True, max_length=100)), + ('required', models.BooleanField(default=False, help_text=b'Determines whether this field is required when creating new objects or editing an existing object.')), + ('is_filterable', models.BooleanField(default=True, help_text=b'This field can be used to filter objects.')), + ('default', models.CharField(blank=True, help_text=b'Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Fields with higher weights appear lower in a form')), + ('obj_type', models.ManyToManyField(help_text=b'The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name=b'Object(s)')), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.CreateModel( + name='CustomFieldChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Higher weights appear lower in the list')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')), + ], + options={ + 'ordering': ['field', 'weight', 'value'], + }, + ), + migrations.CreateModel( + name='CustomFieldValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('obj_id', models.PositiveIntegerField()), + ('serialized_value', models.CharField(max_length=255)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['obj_type', 'obj_id'], + }, + ), + migrations.AlterUniqueTogether( + name='customfieldvalue', + unique_together=set([('field', 'obj_type', 'obj_id')]), + ), + migrations.AlterUniqueTogether( + name='customfieldchoice', + unique_together=set([('field', 'value')]), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 6a32f5738..6d173b62d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,12 +1,37 @@ +from collections import OrderedDict +from datetime import date + from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse from django.template import Template, Context from django.utils.safestring import mark_safe -from dcim.models import Site +CUSTOMFIELD_MODELS = ( + 'site', 'rack', 'device', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM + 'provider', 'circuit', # Circuits + 'tenant', # Tenants +) + +CF_TYPE_TEXT = 100 +CF_TYPE_INTEGER = 200 +CF_TYPE_BOOLEAN = 300 +CF_TYPE_DATE = 400 +CF_TYPE_URL = 500 +CF_TYPE_SELECT = 600 +CUSTOMFIELD_TYPE_CHOICES = ( + (CF_TYPE_TEXT, 'Text'), + (CF_TYPE_INTEGER, 'Integer'), + (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), + (CF_TYPE_DATE, 'Date'), + (CF_TYPE_URL, 'URL'), + (CF_TYPE_SELECT, 'Selection'), +) GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 @@ -40,6 +65,129 @@ ACTION_CHOICES = ( ) +class CustomFieldModel(object): + + def custom_fields(self): + + # Find all custom fields applicable to this type of object + content_type = ContentType.objects.get_for_model(self) + fields = CustomField.objects.filter(obj_type=content_type) + + # If the object exists, populate its custom fields with values + if hasattr(self, 'pk'): + values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field') + 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 CustomField(models.Model): + obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)', + limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, + help_text="The object(s) to which this field applies.") + type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT) + name = models.CharField(max_length=50, unique=True) + label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not " + "provided, the field's name will be used)") + description = models.CharField(max_length=100, blank=True) + required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating " + "new objects or editing an existing object.") + is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.") + default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or " + "\"false\" for booleans. N/A for selection " + "fields.") + weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a " + "form") + + class Meta: + ordering = ['weight', 'name'] + + def __unicode__(self): + return self.label or self.name.replace('_', ' ').capitalize() + + def serialize_value(self, value): + """ + Serialize the given value to a string suitable for storage as a CustomFieldValue + """ + if value is None: + return '' + if self.type == CF_TYPE_BOOLEAN: + return str(int(bool(value))) + if self.type == CF_TYPE_DATE: + return value.strftime('%Y-%m-%d') + if self.type == CF_TYPE_SELECT: + # Could be ModelChoiceField or TypedChoiceField + return str(value.id) if hasattr(value, 'id') else str(value) + return str(value) + + def deserialize_value(self, serialized_value): + """ + Convert a string into the object it represents depending on the type of field + """ + if serialized_value is '': + return None + if self.type == CF_TYPE_INTEGER: + return int(serialized_value) + if self.type == CF_TYPE_BOOLEAN: + return bool(int(serialized_value)) + if self.type == CF_TYPE_DATE: + # Read date as YYYY-MM-DD + return date(*[int(n) for n in serialized_value.split('-')]) + if self.type == CF_TYPE_SELECT: + # return CustomFieldChoice.objects.get(pk=int(serialized_value)) + return self.choices.get(pk=int(serialized_value)) + return serialized_value + + +class CustomFieldValue(models.Model): + field = models.ForeignKey('CustomField', related_name='values') + obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT) + obj_id = models.PositiveIntegerField() + obj = GenericForeignKey('obj_type', 'obj_id') + serialized_value = models.CharField(max_length=255) + + class Meta: + ordering = ['obj_type', 'obj_id'] + unique_together = ['field', 'obj_type', 'obj_id'] + + def __unicode__(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(CustomFieldValue, self).save(*args, **kwargs) + + +class CustomFieldChoice(models.Model): + field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT}, + on_delete=models.CASCADE) + 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 __unicode__(self): + return self.value + + def clean(self): + if self.field.type != CF_TYPE_SELECT: + raise ValidationError("Custom field choices can only be assigned to selection fields.") + + class Graph(models.Model): type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) weight = models.PositiveSmallIntegerField(default=1000) @@ -99,7 +247,7 @@ class ExportTemplate(models.Model): class TopologyMap(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - site = models.ForeignKey(Site, related_name='topology_maps', blank=True, null=True) + site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True) device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions," "one per line. Each line will result in a new tier of the drawing. " "Separate multiple regexes on a line using commas. Devices will be " diff --git a/netbox/extras/tests/__init__.py b/netbox/extras/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py new file mode 100644 index 000000000..791c6a1a2 --- /dev/null +++ b/netbox/extras/tests/test_customfields.py @@ -0,0 +1,97 @@ +from datetime import date + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Site + +from extras.models import ( + CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, + CF_TYPE_SELECT, CF_TYPE_URL, +) + + +class CustomFieldTestCase(TestCase): + + def setUp(self): + + Site.objects.bulk_create([ + Site(name='Site A', slug='site-a'), + Site(name='Site B', slug='site-b'), + Site(name='Site C', slug='site-c'), + ]) + + def test_simple_fields(self): + + DATA = ( + {'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, + {'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, + {'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, + {'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, + {'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, + {'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None}, + {'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, + ) + + obj_type = ContentType.objects.get_for_model(Site) + + for data in DATA: + + # Create a custom field + cf = CustomField(type=data['field_type'], name='my_field', required=False) + cf.save() + cf.obj_type = [obj_type] + cf.save() + + # Assign a value to the first Site + site = Site.objects.first() + cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id) + cfv.value = data['field_value'] + cfv.save() + + # Retrieve the stored value + cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first() + self.assertEqual(cfv.value, data['field_value']) + + # Delete the stored value + cfv.value = data['empty_value'] + cfv.save() + self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0) + + # Delete the custom field + cf.delete() + + def test_select_field(self): + + obj_type = ContentType.objects.get_for_model(Site) + + # Create a custom field + cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) + cf.save() + cf.obj_type = [obj_type] + 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 + site = Site.objects.first() + cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id) + cfv.value = cf.choices.first() + cfv.save() + + # Retrieve the stored value + cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first() + self.assertEqual(str(cfv.value), 'Option A') + + # Delete the stored value + cfv.value = None + cfv.save() + self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0) + + # Delete the custom field + cf.delete() diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a24e1454c..653d9eba5 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from extras.api.serializers import CustomFieldSerializer from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from tenancy.api.serializers import TenantNestedSerializer @@ -9,12 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer # VRFs # -class VRFSerializer(serializers.ModelSerializer): +class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] class VRFNestedSerializer(VRFSerializer): @@ -70,12 +71,12 @@ class RIRNestedSerializer(RIRSerializer): # Aggregates # -class AggregateSerializer(serializers.ModelSerializer): +class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): rir = RIRNestedSerializer() class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] class AggregateNestedSerializer(AggregateSerializer): @@ -106,7 +107,7 @@ class VLANGroupNestedSerializer(VLANGroupSerializer): # VLANs # -class VLANSerializer(serializers.ModelSerializer): +class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = VLANGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -114,7 +115,8 @@ class VLANSerializer(serializers.ModelSerializer): class Meta: model = VLAN - fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name'] + fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', + 'custom_fields'] class VLANNestedSerializer(VLANSerializer): @@ -127,7 +129,7 @@ class VLANNestedSerializer(VLANSerializer): # Prefixes # -class PrefixSerializer(serializers.ModelSerializer): +class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() @@ -136,7 +138,8 @@ class PrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description'] + fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description', + 'custom_fields'] class PrefixNestedSerializer(PrefixSerializer): @@ -149,14 +152,15 @@ class PrefixNestedSerializer(PrefixSerializer): # IP addresses # -class IPAddressSerializer(serializers.ModelSerializer): +class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() interface = InterfaceNestedSerializer() class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside'] + fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside', + 'custom_fields'] class IPAddressNestedSerializer(IPAddressSerializer): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 78d2b7f8e..21ab9335c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,6 +3,7 @@ from rest_framework import generics from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from ipam import filters +from extras.api.views import CustomFieldModelAPIView from . import serializers @@ -10,20 +11,20 @@ from . import serializers # VRFs # -class VRFListView(generics.ListAPIView): +class VRFListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all VRFs """ - queryset = VRF.objects.select_related('tenant') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter -class VRFDetailView(generics.RetrieveAPIView): +class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VRF """ - queryset = VRF.objects.select_related('tenant') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.VRFSerializer @@ -71,20 +72,20 @@ class RIRDetailView(generics.RetrieveAPIView): # Aggregates # -class AggregateListView(generics.ListAPIView): +class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView): """ List aggregates (filterable) """ - queryset = Aggregate.objects.select_related('rir') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter -class AggregateDetailView(generics.RetrieveAPIView): +class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single aggregate """ - queryset = Aggregate.objects.select_related('rir') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') serializer_class = serializers.AggregateSerializer @@ -92,20 +93,22 @@ class AggregateDetailView(generics.RetrieveAPIView): # Prefixes # -class PrefixListView(generics.ListAPIView): +class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView): """ List prefixes (filterable) """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter -class PrefixDetailView(generics.RetrieveAPIView): +class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single prefix """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.PrefixSerializer @@ -113,22 +116,22 @@ class PrefixDetailView(generics.RetrieveAPIView): # IP addresses # -class IPAddressListView(generics.ListAPIView): +class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView): """ List IP addresses (filterable) """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside') + .prefetch_related('nat_outside', 'custom_field_values__field') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter -class IPAddressDetailView(generics.RetrieveAPIView): +class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single IP address """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside') + .prefetch_related('nat_outside', 'custom_field_values__field') serializer_class = serializers.IPAddressSerializer @@ -157,18 +160,20 @@ class VLANGroupDetailView(generics.RetrieveAPIView): # VLANs # -class VLANListView(generics.ListAPIView): +class VLANListView(CustomFieldModelAPIView, generics.ListAPIView): """ List VLANs (filterable) """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter -class VLANDetailView(generics.RetrieveAPIView): +class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VLAN """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 3a73caf84..be2d127b5 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -5,12 +5,13 @@ from netaddr.core import AddrFormatError from django.db.models import Q from dcim.models import Site, Device, Interface +from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role -class VRFFilter(django_filters.FilterSet): +class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -44,7 +45,7 @@ class VRFFilter(django_filters.FilterSet): fields = ['name', 'rd'] -class AggregateFilter(django_filters.FilterSet): +class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -75,7 +76,7 @@ class AggregateFilter(django_filters.FilterSet): return queryset.filter(qs_filter) -class PrefixFilter(django_filters.FilterSet): +class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -186,7 +187,7 @@ class PrefixFilter(django_filters.FilterSet): ) -class IPAddressFilter(django_filters.FilterSet): +class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -300,7 +301,7 @@ class VLANGroupFilter(django_filters.FilterSet): fields = ['site_id', 'site'] -class VLANFilter(django_filters.FilterSet): +class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 00374ef36..938f5aeb6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,9 +1,8 @@ -from netaddr import IPNetwork - from django import forms from django.db.models import Count from dcim.models import Site, Device, Interface +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField @@ -33,7 +32,7 @@ def bulkedit_vrf_choices(): # VRFs # -class VRFForm(forms.ModelForm, BootstrapMixin): +class VRFForm(BootstrapMixin, CustomFieldForm): class Meta: model = VRF @@ -59,7 +58,7 @@ class VRFImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=VRFFromCSVForm) -class VRFBulkEditForm(forms.Form, BootstrapMixin): +class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') description = forms.CharField(max_length=100, required=False) @@ -70,7 +69,8 @@ def vrf_tenant_choices(): return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] -class VRFFilterForm(forms.Form, BootstrapMixin): +class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VRF tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -91,7 +91,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin): # Aggregates # -class AggregateForm(forms.ModelForm, BootstrapMixin): +class AggregateForm(BootstrapMixin, CustomFieldForm): class Meta: model = Aggregate @@ -116,7 +116,7 @@ class AggregateImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=AggregateFromCSVForm) -class AggregateBulkEditForm(forms.Form, BootstrapMixin): +class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') date_added = forms.DateField(required=False) @@ -128,7 +128,8 @@ def aggregate_rir_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] -class AggregateFilterForm(forms.Form, BootstrapMixin): +class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Aggregate rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', widget=forms.SelectMultiple(attrs={'size': 8})) @@ -149,7 +150,7 @@ class RoleForm(forms.ModelForm, BootstrapMixin): # Prefixes # -class PrefixForm(forms.ModelForm, BootstrapMixin): +class PrefixForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(attrs={'filter-for': 'vlan'})) vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', @@ -251,7 +252,7 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=PrefixFromCSVForm) -class PrefixBulkEditForm(forms.Form, BootstrapMixin): +class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') @@ -288,7 +289,8 @@ def prefix_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] -class PrefixFilterForm(forms.Form, BootstrapMixin): +class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Prefix parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Network', })) @@ -309,7 +311,7 @@ class PrefixFilterForm(forms.Form, BootstrapMixin): # IP addresses # -class IPAddressForm(forms.ModelForm, BootstrapMixin): +class IPAddressForm(BootstrapMixin, CustomFieldForm): nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(attrs={'filter-for': 'nat_device'})) nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', @@ -425,7 +427,7 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=IPAddressFromCSVForm) -class IPAddressBulkEditForm(forms.Form, BootstrapMixin): +class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') @@ -441,7 +443,8 @@ def ipaddress_vrf_choices(): return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices] -class IPAddressFilterForm(forms.Form, BootstrapMixin): +class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) @@ -478,7 +481,7 @@ class VLANGroupFilterForm(forms.Form, BootstrapMixin): # VLANs # -class VLANForm(forms.ModelForm, BootstrapMixin): +class VLANForm(BootstrapMixin, CustomFieldForm): group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect( api_url='/api/ipam/vlan-groups/?site_id={{site}}', )) @@ -539,7 +542,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=VLANFromCSVForm) -class VLANBulkEditForm(forms.Form, BootstrapMixin): +class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) @@ -576,7 +579,8 @@ def vlan_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] -class VLANFilterForm(forms.Form, BootstrapMixin): +class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VLAN site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 1639daee6..0b8b09049 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,7 @@ from netaddr import IPNetwork, cidr_merge from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,6 +9,7 @@ from django.db import models from django.db.models.expressions import RawSQL from dcim.models import Interface +from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel @@ -40,7 +42,7 @@ STATUS_CHOICE_CLASSES = { } -class VRF(CreatedUpdatedModel): +class VRF(CreatedUpdatedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -52,6 +54,7 @@ class VRF(CreatedUpdatedModel): enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space', help_text="Prevent duplicate prefixes/IP addresses within this VRF") description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['name'] @@ -94,7 +97,7 @@ class RIR(models.Model): return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) -class Aggregate(CreatedUpdatedModel): +class Aggregate(CreatedUpdatedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -104,6 +107,7 @@ class Aggregate(CreatedUpdatedModel): rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') date_added = models.DateField(blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['family', 'prefix'] @@ -223,7 +227,7 @@ class PrefixQuerySet(models.QuerySet): return filter(lambda p: p.depth <= limit, queryset) -class Prefix(CreatedUpdatedModel): +class Prefix(CreatedUpdatedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -240,6 +244,7 @@ class Prefix(CreatedUpdatedModel): status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1) role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = PrefixQuerySet.as_manager() @@ -311,7 +316,7 @@ class IPAddressManager(models.Manager): return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host') -class IPAddress(CreatedUpdatedModel): +class IPAddress(CreatedUpdatedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -332,6 +337,7 @@ class IPAddress(CreatedUpdatedModel): nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT IP (inside)') description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = IPAddressManager() @@ -417,7 +423,7 @@ class VLANGroup(models.Model): return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) -class VLAN(CreatedUpdatedModel): +class VLAN(CreatedUpdatedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, @@ -437,6 +443,7 @@ class VLAN(CreatedUpdatedModel): status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['site', 'group', 'vid'] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a2dd6c313..c7c5a46c6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -111,6 +111,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_vrf' model = VRF form_class = forms.VRFForm + template_name = 'ipam/vrf_edit.html' cancel_url = 'ipam:vrf_list' @@ -135,19 +136,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/vrf_bulk_edit.html' default_redirect_url = 'ipam:vrf_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' @@ -235,6 +223,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_aggregate' model = Aggregate form_class = forms.AggregateForm + template_name = 'ipam/aggregate_edit.html' cancel_url = 'ipam:aggregate_list' @@ -259,15 +248,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/aggregate_bulk_edit.html' default_redirect_url = 'ipam:aggregate_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['rir', 'date_added', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' @@ -373,6 +353,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_prefix' model = Prefix form_class = forms.PrefixForm + template_name = 'ipam/prefix_edit.html' fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan'] cancel_url = 'ipam:prefix_list' @@ -398,20 +379,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/prefix_bulk_edit.html' default_redirect_url = 'ipam:prefix_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['vrf', 'tenant']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['site', 'status', 'role', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' @@ -524,20 +491,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/ipaddress_bulk_edit.html' default_redirect_url = 'ipam:ipaddress_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['vrf', 'tenant']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' @@ -601,6 +554,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_vlan' model = VLAN form_class = forms.VLANForm + template_name = 'ipam/vlan_edit.html' cancel_url = 'ipam:vlan_list' @@ -625,19 +579,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/vlan_bulk_edit.html' default_redirect_url = 'ipam:vlan_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['site', 'group', 'status', 'role', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 351871675..14ac4fa78 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -205,15 +205,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'secrets/secret_bulk_edit.html' default_redirect_url = 'secrets:secret_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['role', 'name']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 94eead673..863b0a0a2 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -23,6 +23,14 @@ {% render_field form.commit_rate %} + {% if form.custom_fields %} +
{{ field }} | ++ {% if value == True %} + + {% elif value == False %} + + {% elif field.type == 500 and value %} + {{ value|urlizetrunc:75 }} + {% elif value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + N/A + {% endif %} + | +