Merge pull request #504 from digitalocean/custom-fields

Custom fields
This commit is contained in:
Jeremy Stretch 2016-08-24 09:45:50 -04:00 committed by GitHub
commit 8341800a85
59 changed files with 1155 additions and 329 deletions

View File

@ -1,5 +1,32 @@
This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value. 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 # 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. 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.

View File

@ -2,6 +2,7 @@ from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit from circuits.models import Provider, CircuitType, Circuit
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer from tenancy.api.serializers import TenantNestedSerializer
@ -9,11 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer
# Providers # Providers
# #
class ProviderSerializer(serializers.ModelSerializer): class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = Provider 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): class ProviderNestedSerializer(ProviderSerializer):
@ -43,7 +45,7 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
# Circuits # Circuits
# #
class CircuitSerializer(serializers.ModelSerializer): class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
provider = ProviderNestedSerializer() provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer() type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
@ -53,7 +55,7 @@ class CircuitSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Circuit model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', 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): class CircuitNestedSerializer(CircuitSerializer):

View File

@ -3,22 +3,23 @@ from rest_framework import generics
from circuits.models import Provider, CircuitType, Circuit from circuits.models import Provider, CircuitType, Circuit
from circuits.filters import CircuitFilter from circuits.filters import CircuitFilter
from extras.api.views import CustomFieldModelAPIView
from . import serializers from . import serializers
class ProviderListView(generics.ListAPIView): class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List all providers List all providers
""" """
queryset = Provider.objects.all() queryset = Provider.objects.prefetch_related('custom_field_values__field')
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
class ProviderDetailView(generics.RetrieveAPIView): class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single provider Retrieve a single provider
""" """
queryset = Provider.objects.all() queryset = Provider.objects.prefetch_related('custom_field_values__field')
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
@ -38,18 +39,20 @@ class CircuitTypeDetailView(generics.RetrieveAPIView):
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer
class CircuitListView(generics.ListAPIView): class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List circuits (filterable) 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 serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter filter_class = CircuitFilter
class CircuitDetailView(generics.RetrieveAPIView): class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single circuit 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 serializer_class = serializers.CircuitSerializer

View File

@ -3,11 +3,12 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.models import Site from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from .models import Provider, Circuit, CircuitType from .models import Provider, Circuit, CircuitType
class ProviderFilter(django_filters.FilterSet): class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='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( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',

View File

@ -2,6 +2,7 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL 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.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -15,7 +16,7 @@ from .models import Circuit, CircuitType, Provider
# Providers # Providers
# #
class ProviderForm(forms.ModelForm, BootstrapMixin): class ProviderForm(BootstrapMixin, CustomFieldForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
@ -46,7 +47,7 @@ class ProviderImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ProviderFromCSVForm) csv = CSVDataField(csv_form=ProviderFromCSVForm)
class ProviderBulkEditForm(forms.Form, BootstrapMixin): class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
asn = forms.IntegerField(required=False, label='ASN') asn = forms.IntegerField(required=False, label='ASN')
account = forms.CharField(max_length=30, required=False, label='Account number') 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] 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, site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
@ -82,7 +84,7 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
# Circuits # Circuits
# #
class CircuitForm(forms.ModelForm, BootstrapMixin): class CircuitForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) 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', rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
@ -177,7 +179,7 @@ class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm) csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(forms.Form, BootstrapMixin): class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.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] 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) type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -1,13 +1,15 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import Site, Interface from dcim.models import Site, Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel 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 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. 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') noc_contact = models.TextField(blank=True, verbose_name='NOC contact')
admin_contact = models.TextField(blank=True, verbose_name='Admin contact') admin_contact = models.TextField(blank=True, verbose_name='Admin contact')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -58,7 +61,7 @@ class CircuitType(models.Model):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) 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 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 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') 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)') pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']

View File

@ -66,15 +66,6 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'circuits/provider_bulk_edit.html' template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list' 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): class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider' permission_required = 'circuits.delete_provider'
@ -159,19 +150,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'circuits/circuit_bulk_edit.html' template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list' 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): class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit' permission_required = 'circuits.delete_circuit'

View File

@ -6,6 +6,7 @@ from dcim.models import (
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, 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 from tenancy.api.serializers import TenantNestedSerializer
@ -13,13 +14,13 @@ from tenancy.api.serializers import TenantNestedSerializer
# Sites # Sites
# #
class SiteSerializer(serializers.ModelSerializer): class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
class Meta: class Meta:
model = Site model = Site
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', 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): class SiteNestedSerializer(SiteSerializer):
@ -68,7 +69,7 @@ class RackRoleNestedSerializer(RackRoleSerializer):
# #
class RackSerializer(serializers.ModelSerializer): class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer() site = SiteNestedSerializer()
group = RackGroupNestedSerializer() group = RackGroupNestedSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
@ -77,7 +78,7 @@ class RackSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Rack model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments'] 'u_height', 'comments', 'custom_fields']
class RackNestedSerializer(RackSerializer): class RackNestedSerializer(RackSerializer):
@ -92,7 +93,7 @@ class RackDetailSerializer(RackSerializer):
class Meta(RackSerializer.Meta): class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 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): def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT) units = obj.get_rack_units(face=RACK_FACE_FRONT)
@ -237,7 +238,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
fields = ['id', 'family', 'address'] fields = ['id', 'family', 'address']
class DeviceSerializer(serializers.ModelSerializer): class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
device_type = DeviceTypeNestedSerializer() device_type = DeviceTypeNestedSerializer()
device_role = DeviceRoleNestedSerializer() device_role = DeviceRoleNestedSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
@ -252,7 +253,7 @@ class DeviceSerializer(serializers.ModelSerializer):
model = Device model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', '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): def get_parent_device(self, obj):
try: try:

View File

@ -5,6 +5,7 @@ from rest_framework.settings import api_settings
from rest_framework.views import APIView from rest_framework.views import APIView
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 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, InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
) )
from dcim import filters from dcim import filters
from .exceptions import MissingFilterException from extras.api.views import CustomFieldModelAPIView
from . import serializers
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
from utilities.api import ServiceUnavailable from utilities.api import ServiceUnavailable
from .exceptions import MissingFilterException
from . import serializers
# #
# Sites # Sites
# #
class SiteListView(generics.ListAPIView): class SiteListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List all sites 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 serializer_class = serializers.SiteSerializer
class SiteDetailView(generics.RetrieveAPIView): class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single site 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 serializer_class = serializers.SiteSerializer
@ -84,20 +86,22 @@ class RackRoleDetailView(generics.RetrieveAPIView):
# Racks # Racks
# #
class RackListView(generics.ListAPIView): class RackListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List racks (filterable) 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 serializer_class = serializers.RackSerializer
filter_class = filters.RackFilter filter_class = filters.RackFilter
class RackDetailView(generics.RetrieveAPIView): class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single rack 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 serializer_class = serializers.RackDetailSerializer
@ -209,24 +213,25 @@ class PlatformDetailView(generics.RetrieveAPIView):
# Devices # Devices
# #
class DeviceListView(generics.ListAPIView): class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List devices (filterable) List devices (filterable)
""" """
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside', '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 serializer_class = serializers.DeviceSerializer
filter_class = filters.DeviceFilter filter_class = filters.DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
class DeviceDetailView(generics.RetrieveAPIView): class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single device Retrieve a single device
""" """
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', 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 serializer_class = serializers.DeviceSerializer
@ -426,6 +431,13 @@ class RelatedConnectionsView(APIView):
Retrieve all connections related to a given console/power/interface connection 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): def get(self, request):
peer_device = request.GET.get('peer-device') peer_device = request.GET.get('peer-device')
@ -450,7 +462,7 @@ class RelatedConnectionsView(APIView):
# Initialize response skeleton # Initialize response skeleton
response = { response = {
'device': serializers.DeviceSerializer(device).data, 'device': serializers.DeviceSerializer(device, context={'view': self}).data,
'console-ports': [], 'console-ports': [],
'power-ports': [], 'power-ports': [],
'interfaces': [], 'interfaces': [],

View File

@ -2,14 +2,15 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from .models import ( from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, 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( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',
@ -58,7 +59,7 @@ class RackGroupFilter(django_filters.FilterSet):
fields = ['site_id', 'site'] fields = ['site_id', 'site']
class RackFilter(django_filters.FilterSet): class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',
@ -139,7 +140,7 @@ class DeviceTypeFilter(django_filters.FilterSet):
'is_network_device'] 'is_network_device']
class DeviceFilter(django_filters.FilterSet): class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',

View File

@ -3,6 +3,7 @@ import re
from django import forms from django import forms
from django.db.models import Count, Q from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.forms import bulkedit_tenant_choices from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
@ -78,7 +79,7 @@ def bulkedit_rackrole_choices():
# Sites # Sites
# #
class SiteForm(forms.ModelForm, BootstrapMixin): class SiteForm(BootstrapMixin, CustomFieldForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
@ -111,7 +112,7 @@ class SiteImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SiteFromCSVForm) csv = CSVDataField(csv_form=SiteFromCSVForm)
class SiteBulkEditForm(forms.Form, BootstrapMixin): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') 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] 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, tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
@ -164,7 +166,7 @@ class RackRoleForm(forms.ModelForm, BootstrapMixin):
# Racks # Racks
# #
class RackForm(forms.ModelForm, BootstrapMixin): class RackForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}', api_url='/api/dcim/rack-groups/?site_id={{site}}',
)) ))
@ -240,7 +242,7 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=RackFromCSVForm) csv = CSVDataField(csv_form=RackFromCSVForm)
class RackBulkEditForm(forms.Form, BootstrapMixin): class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') 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] 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, site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
@ -404,7 +407,7 @@ class PlatformForm(forms.ModelForm, BootstrapMixin):
# Devices # Devices
# #
class DeviceForm(forms.ModelForm, BootstrapMixin): class DeviceForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}', api_url='/api/dcim/racks/?site_id={{site}}',
@ -613,7 +616,7 @@ class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
class DeviceBulkEditForm(forms.Form, BootstrapMixin): class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') 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] 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, site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',

View File

@ -1,12 +1,15 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings 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.exceptions import MultipleObjectsReturned, ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist from django.db.models import Count, Q, ObjectDoesNotExist
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
from extras.rpc import RPC_CLIENTS from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.fields import NullableCharField from utilities.fields import NullableCharField
@ -213,7 +216,7 @@ class SiteManager(NaturalOrderByManager):
return self.natural_order_by('name') 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 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). 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) physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True) shipping_address = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = SiteManager() objects = SiteManager()
@ -320,7 +324,7 @@ class RackManager(NaturalOrderByManager):
return self.natural_order_by('site__name', 'name') 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. 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. 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)', u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]) validators=[MinValueValidator(1), MaxValueValidator(100)])
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = RackManager() objects = RackManager()
@ -719,7 +724,7 @@ class DeviceManager(NaturalOrderByManager):
return self.natural_order_by('name') 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, 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. 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, primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv6') blank=True, null=True, verbose_name='Primary IPv6')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = DeviceManager() objects = DeviceManager()

View File

@ -21,6 +21,7 @@ class SiteTest(APITestCase):
'physical_address', 'physical_address',
'shipping_address', 'shipping_address',
'comments', 'comments',
'custom_fields',
'count_prefixes', 'count_prefixes',
'count_vlans', 'count_vlans',
'count_racks', 'count_racks',
@ -46,7 +47,8 @@ class SiteTest(APITestCase):
'type', 'type',
'width', 'width',
'u_height', 'u_height',
'comments' 'comments',
'custom_fields',
] ]
graph_fields = [ graph_fields = [
@ -125,7 +127,8 @@ class RackTest(APITestCase):
'type', 'type',
'width', 'width',
'u_height', 'u_height',
'comments' 'comments',
'custom_fields',
] ]
detail_fields = [ detail_fields = [
@ -141,6 +144,7 @@ class RackTest(APITestCase):
'width', 'width',
'u_height', 'u_height',
'comments', 'comments',
'custom_fields',
'front_units', 'front_units',
'rear_units' 'rear_units'
] ]
@ -337,6 +341,7 @@ class DeviceTest(APITestCase):
'primary_ip4', 'primary_ip4',
'primary_ip6', 'primary_ip6',
'comments', 'comments',
'custom_fields',
] ]
nested_fields = ['id', 'name', 'display_name'] nested_fields = ['id', 'name', 'display_name']

View File

@ -122,16 +122,6 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/site_bulk_edit.html' template_name = 'dcim/site_bulk_edit.html'
default_redirect_url = 'dcim:site_list' 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 # Rack groups
@ -248,20 +238,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/rack_bulk_edit.html' template_name = 'dcim/rack_bulk_edit.html'
default_redirect_url = 'dcim:rack_list' 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): class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack' permission_required = 'dcim.delete_rack'
@ -372,15 +348,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/devicetype_bulk_edit.html' template_name = 'dcim/devicetype_bulk_edit.html'
default_redirect_url = 'dcim:devicetype_list' 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): class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype' permission_required = 'dcim.delete_devicetype'
@ -682,23 +649,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/device_bulk_edit.html' template_name = 'dcim/device_bulk_edit.html'
default_redirect_url = 'dcim:device_list' 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): class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device' permission_required = 'dcim.delete_device'

View File

@ -1,6 +1,36 @@
from django import forms
from django.contrib import admin 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) @admin.register(Graph)

View File

@ -1,6 +1,44 @@
from rest_framework import serializers 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): class GraphSerializer(serializers.ModelSerializer):

View File

@ -1,9 +1,8 @@
import graphviz import graphviz
from rest_framework import generics from rest_framework import generics
from rest_framework.views import APIView 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.db.models import Q
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 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 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): class GraphListView(generics.ListAPIView):
""" """
Returns a list of relevant graphs Returns a list of relevant graphs

33
netbox/extras/filters.py Normal file
View File

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

160
netbox/extras/forms.py Normal file
View File

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

View File

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

View File

@ -1,12 +1,37 @@
from collections import OrderedDict
from datetime import date
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.utils.safestring import mark_safe 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_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200 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): class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000) weight = models.PositiveSmallIntegerField(default=1000)
@ -99,7 +247,7 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model): class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(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," 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. " "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 " "Separate multiple regexes on a line using commas. Devices will be "

View File

View File

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

View File

@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer 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 ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from tenancy.api.serializers import TenantNestedSerializer from tenancy.api.serializers import TenantNestedSerializer
@ -9,12 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer
# VRFs # VRFs
# #
class VRFSerializer(serializers.ModelSerializer): class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer):
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
class Meta: class Meta:
model = VRF model = VRF
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
class VRFNestedSerializer(VRFSerializer): class VRFNestedSerializer(VRFSerializer):
@ -70,12 +71,12 @@ class RIRNestedSerializer(RIRSerializer):
# Aggregates # Aggregates
# #
class AggregateSerializer(serializers.ModelSerializer): class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer):
rir = RIRNestedSerializer() rir = RIRNestedSerializer()
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
class AggregateNestedSerializer(AggregateSerializer): class AggregateNestedSerializer(AggregateSerializer):
@ -106,7 +107,7 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
# VLANs # VLANs
# #
class VLANSerializer(serializers.ModelSerializer): class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer() site = SiteNestedSerializer()
group = VLANGroupNestedSerializer() group = VLANGroupNestedSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
@ -114,7 +115,8 @@ class VLANSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = VLAN 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): class VLANNestedSerializer(VLANSerializer):
@ -127,7 +129,7 @@ class VLANNestedSerializer(VLANSerializer):
# Prefixes # Prefixes
# #
class PrefixSerializer(serializers.ModelSerializer): class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer() site = SiteNestedSerializer()
vrf = VRFTenantSerializer() vrf = VRFTenantSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
@ -136,7 +138,8 @@ class PrefixSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Prefix 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): class PrefixNestedSerializer(PrefixSerializer):
@ -149,14 +152,15 @@ class PrefixNestedSerializer(PrefixSerializer):
# IP addresses # IP addresses
# #
class IPAddressSerializer(serializers.ModelSerializer): class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
vrf = VRFTenantSerializer() vrf = VRFTenantSerializer()
tenant = TenantNestedSerializer() tenant = TenantNestedSerializer()
interface = InterfaceNestedSerializer() interface = InterfaceNestedSerializer()
class Meta: class Meta:
model = IPAddress 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): class IPAddressNestedSerializer(IPAddressSerializer):

View File

@ -3,6 +3,7 @@ from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam import filters from ipam import filters
from extras.api.views import CustomFieldModelAPIView
from . import serializers from . import serializers
@ -10,20 +11,20 @@ from . import serializers
# VRFs # VRFs
# #
class VRFListView(generics.ListAPIView): class VRFListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List all VRFs 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 serializer_class = serializers.VRFSerializer
filter_class = filters.VRFFilter filter_class = filters.VRFFilter
class VRFDetailView(generics.RetrieveAPIView): class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single VRF 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 serializer_class = serializers.VRFSerializer
@ -71,20 +72,20 @@ class RIRDetailView(generics.RetrieveAPIView):
# Aggregates # Aggregates
# #
class AggregateListView(generics.ListAPIView): class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List aggregates (filterable) 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 serializer_class = serializers.AggregateSerializer
filter_class = filters.AggregateFilter filter_class = filters.AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView): class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single aggregate 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 serializer_class = serializers.AggregateSerializer
@ -92,20 +93,22 @@ class AggregateDetailView(generics.RetrieveAPIView):
# Prefixes # Prefixes
# #
class PrefixListView(generics.ListAPIView): class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List prefixes (filterable) 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 serializer_class = serializers.PrefixSerializer
filter_class = filters.PrefixFilter filter_class = filters.PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView): class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single prefix 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 serializer_class = serializers.PrefixSerializer
@ -113,22 +116,22 @@ class PrefixDetailView(generics.RetrieveAPIView):
# IP addresses # IP addresses
# #
class IPAddressListView(generics.ListAPIView): class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List IP addresses (filterable) List IP addresses (filterable)
""" """
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ 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 serializer_class = serializers.IPAddressSerializer
filter_class = filters.IPAddressFilter filter_class = filters.IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView): class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single IP address Retrieve a single IP address
""" """
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ 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 serializer_class = serializers.IPAddressSerializer
@ -157,18 +160,20 @@ class VLANGroupDetailView(generics.RetrieveAPIView):
# VLANs # VLANs
# #
class VLANListView(generics.ListAPIView): class VLANListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List VLANs (filterable) 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 serializer_class = serializers.VLANSerializer
filter_class = filters.VLANFilter filter_class = filters.VLANFilter
class VLANDetailView(generics.RetrieveAPIView): class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single VLAN 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 serializer_class = serializers.VLANSerializer

View File

@ -5,12 +5,13 @@ from netaddr.core import AddrFormatError
from django.db.models import Q from django.db.models import Q
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role 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( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',
@ -44,7 +45,7 @@ class VRFFilter(django_filters.FilterSet):
fields = ['name', 'rd'] fields = ['name', 'rd']
class AggregateFilter(django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',
@ -75,7 +76,7 @@ class AggregateFilter(django_filters.FilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class PrefixFilter(django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='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( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',
@ -300,7 +301,7 @@ class VLANGroupFilter(django_filters.FilterSet):
fields = ['site_id', 'site'] fields = ['site_id', 'site']
class VLANFilter(django_filters.FilterSet): class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',

View File

@ -1,9 +1,8 @@
from netaddr import IPNetwork
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
@ -33,7 +32,7 @@ def bulkedit_vrf_choices():
# VRFs # VRFs
# #
class VRFForm(forms.ModelForm, BootstrapMixin): class VRFForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = VRF model = VRF
@ -59,7 +58,7 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VRFFromCSVForm) csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(forms.Form, BootstrapMixin): class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
description = forms.CharField(max_length=100, required=False) 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] 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, tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
@ -91,7 +91,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
# Aggregates # Aggregates
# #
class AggregateForm(forms.ModelForm, BootstrapMixin): class AggregateForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = Aggregate model = Aggregate
@ -116,7 +116,7 @@ class AggregateImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=AggregateFromCSVForm) csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(forms.Form, BootstrapMixin): class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
date_added = forms.DateField(required=False) 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] 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', rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
@ -149,7 +150,7 @@ class RoleForm(forms.ModelForm, BootstrapMixin):
# Prefixes # Prefixes
# #
class PrefixForm(forms.ModelForm, BootstrapMixin): class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'})) widget=forms.Select(attrs={'filter-for': 'vlan'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='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) csv = CSVDataField(csv_form=PrefixFromCSVForm)
class PrefixBulkEditForm(forms.Form, BootstrapMixin): class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') 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] 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={ parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network', 'placeholder': 'Network',
})) }))
@ -309,7 +311,7 @@ class PrefixFilterForm(forms.Form, BootstrapMixin):
# IP addresses # IP addresses
# #
class IPAddressForm(forms.ModelForm, BootstrapMixin): class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'})) widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='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) csv = CSVDataField(csv_form=IPAddressFromCSVForm)
class IPAddressBulkEditForm(forms.Form, BootstrapMixin): class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') 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') 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] 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={ parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Prefix', 'placeholder': 'Prefix',
})) }))
@ -478,7 +481,7 @@ class VLANGroupFilterForm(forms.Form, BootstrapMixin):
# VLANs # VLANs
# #
class VLANForm(forms.ModelForm, BootstrapMixin): class VLANForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect( group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}', api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)) ))
@ -539,7 +542,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VLANFromCSVForm) csv = CSVDataField(csv_form=VLANFromCSVForm)
class VLANBulkEditForm(forms.Form, BootstrapMixin): class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.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] 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, site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',

View File

@ -1,6 +1,7 @@
from netaddr import IPNetwork, cidr_merge from netaddr import IPNetwork, cidr_merge
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -8,6 +9,7 @@ from django.db import models
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from dcim.models import Interface from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel 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 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 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', enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
help_text="Prevent duplicate prefixes/IP addresses within this VRF") help_text="Prevent duplicate prefixes/IP addresses within this VRF")
description = models.CharField(max_length=100, blank=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: class Meta:
ordering = ['name'] ordering = ['name']
@ -94,7 +97,7 @@ class RIR(models.Model):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) 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 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. 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') rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
date_added = models.DateField(blank=True, null=True) date_added = models.DateField(blank=True, null=True)
description = models.CharField(max_length=100, blank=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: class Meta:
ordering = ['family', 'prefix'] ordering = ['family', 'prefix']
@ -223,7 +227,7 @@ class PrefixQuerySet(models.QuerySet):
return filter(lambda p: p.depth <= limit, 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 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 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) 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) role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=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() 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') 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 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 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, nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
null=True, verbose_name='NAT IP (inside)') null=True, verbose_name='NAT IP (inside)')
description = models.CharField(max_length=100, blank=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 = IPAddressManager() objects = IPAddressManager()
@ -417,7 +423,7 @@ class VLANGroup(models.Model):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) 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 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, 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) 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) role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=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: class Meta:
ordering = ['site', 'group', 'vid'] ordering = ['site', 'group', 'vid']

View File

@ -111,6 +111,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vrf' permission_required = 'ipam.change_vrf'
model = VRF model = VRF
form_class = forms.VRFForm form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
cancel_url = 'ipam:vrf_list' cancel_url = 'ipam:vrf_list'
@ -135,19 +136,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/vrf_bulk_edit.html' template_name = 'ipam/vrf_bulk_edit.html'
default_redirect_url = 'ipam:vrf_list' 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): class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf' permission_required = 'ipam.delete_vrf'
@ -235,6 +223,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_aggregate' permission_required = 'ipam.change_aggregate'
model = Aggregate model = Aggregate
form_class = forms.AggregateForm form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
cancel_url = 'ipam:aggregate_list' cancel_url = 'ipam:aggregate_list'
@ -259,15 +248,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/aggregate_bulk_edit.html' template_name = 'ipam/aggregate_bulk_edit.html'
default_redirect_url = 'ipam:aggregate_list' 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): class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate' permission_required = 'ipam.delete_aggregate'
@ -373,6 +353,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_prefix' permission_required = 'ipam.change_prefix'
model = Prefix model = Prefix
form_class = forms.PrefixForm form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan'] fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
cancel_url = 'ipam:prefix_list' cancel_url = 'ipam:prefix_list'
@ -398,20 +379,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/prefix_bulk_edit.html' template_name = 'ipam/prefix_bulk_edit.html'
default_redirect_url = 'ipam:prefix_list' 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): class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix' permission_required = 'ipam.delete_prefix'
@ -524,20 +491,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/ipaddress_bulk_edit.html' template_name = 'ipam/ipaddress_bulk_edit.html'
default_redirect_url = 'ipam:ipaddress_list' 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): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress' permission_required = 'ipam.delete_ipaddress'
@ -601,6 +554,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlan' permission_required = 'ipam.change_vlan'
model = VLAN model = VLAN
form_class = forms.VLANForm form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
cancel_url = 'ipam:vlan_list' cancel_url = 'ipam:vlan_list'
@ -625,19 +579,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/vlan_bulk_edit.html' template_name = 'ipam/vlan_bulk_edit.html'
default_redirect_url = 'ipam:vlan_list' 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): class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan' permission_required = 'ipam.delete_vlan'

View File

@ -205,15 +205,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'secrets/secret_bulk_edit.html' template_name = 'secrets/secret_bulk_edit.html'
default_redirect_url = 'secrets:secret_list' 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): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret' permission_required = 'secrets.delete_secret'

View File

@ -23,6 +23,14 @@
{% render_field form.commit_rate %} {% render_field form.commit_rate %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Termination</strong></div> <div class="panel-heading"><strong>Termination</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -105,6 +105,9 @@
</tr> </tr>
</table> </table>
</div> </div>
{% with provider.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>

View File

@ -19,6 +19,14 @@
{% render_field form.admin_contact %} {% render_field form.admin_contact %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div> <div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -144,6 +144,9 @@
</tr> </tr>
</table> </table>
</div> </div>
{% with device.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

View File

@ -63,6 +63,14 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div> <div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -132,6 +132,9 @@
</tr> </tr>
</table> </table>
</div> </div>
{% with rack.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Non-Racked Devices</strong> <strong>Non-Racked Devices</strong>

View File

@ -16,6 +16,14 @@
{% render_field form.u_height %} {% render_field form.u_height %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div> <div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -111,6 +111,9 @@
</tr> </tr>
</table> </table>
</div> </div>
{% with site.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>

View File

@ -6,7 +6,7 @@
{% block select_objects_table %} {% block select_objects_table %}
{% for site in selected_objects %} {% for site in selected_objects %}
<tr> <tr>
<td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site.slug }}</a></td> <td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site }}</a></td>
<td>{{ site.tenant }}</td> <td>{{ site.tenant }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -14,6 +14,14 @@
{% render_field form.shipping_address %} {% render_field form.shipping_address %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div> <div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -0,0 +1,29 @@
{% if custom_fields %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Custom Fields</strong>
</div>
<table class="table table-hover panel-body">
{% for field, value in custom_fields.items %}
<tr>
<td>{{ field }}</td>
<td>
{% if value == True %}
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
{% elif value == False %}
<i class="glyphicon glyphicon-remove text-danger" title="False"></i>
{% elif field.type == 500 and value %}
{{ value|urlizetrunc:75 }}
{% elif value %}
{{ value }}
{% elif field.required %}
<span class="text-warning">Not defined</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}

View File

@ -1,27 +1,32 @@
{% load form_helpers %} {% load form_helpers %}
<div class="panel panel-default"> {% if filter_form %}
<div class="panel-heading"> <div class="panel panel-default">
<span class="fa fa-filter" aria-hidden="true"></span> <div class="panel-heading">
<strong>Filter</strong> <span class="fa fa-filter" aria-hidden="true"></span>
</div> <strong>Filter</strong>
<div class="panel-body"> </div>
<form action="." method="get" class="form"> <div class="panel-body">
{% for field in filter_form %} <form action="." method="get" class="form">
<div class="form-group"> {% for field in filter_form %}
{% if field|widget_type == 'checkboxinput' %} <div class="form-group">
<label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label> {% if field|widget_type == 'checkboxinput' %}
{% else %} <label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
{{ field.label_tag }} {% else %}
{{ field }} {{ field.label_tag }}
{% endif %} {{ field }}
{% endif %}
</div>
{% endfor %}
<div class="text-right">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span> Apply
</button>
<a href="." class="btn btn-default">
<span class="fa fa-remove" aria-hidden="true"></span> Clear
</a>
</div> </div>
{% endfor %} </form>
<div class="text-right"> </div>
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span> Apply filters
</button>
</div>
</form>
</div> </div>
</div> {% endif %}

View File

@ -81,6 +81,11 @@
</div> </div>
{% include 'inc/created_updated.html' with obj=aggregate %} {% include 'inc/created_updated.html' with obj=aggregate %}
</div> </div>
<div class="col-md-6">
{% with aggregate.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">

View File

@ -0,0 +1,22 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Aggregate</strong></div>
<div class="panel-body">
{% render_field form.prefix %}
{% render_field form.rir %}
{% render_field form.date_added %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -51,6 +51,14 @@
{% render_field form.nat_inside %} {% render_field form.nat_inside %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}

View File

@ -0,0 +1,26 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Prefix</strong></div>
<div class="panel-body">
{% render_field form.prefix %}
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.site %}
{% render_field form.vlan %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>VLAN</strong></div>
<div class="panel-body">
{% render_field form.site %}
{% render_field form.group %}
{% render_field form.vid %}
{% render_field form.name %}
{% render_field form.tenant %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>VRF</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.rd %}
{% render_field form.tenant %}
{% render_field form.enforce_unique %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -65,6 +65,9 @@
</tr> </tr>
</table> </table>
</div> </div>
{% with tenant.custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>

View File

@ -12,6 +12,14 @@
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div> <div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -0,0 +1,7 @@
{% load form_helpers %}
{% for field in form %}
{% if field.name in form.custom_fields %}
{% render_field field %}
{% endif %}
{% endfor %}

View File

@ -1,5 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from extras.api.serializers import CustomFieldSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -24,12 +25,12 @@ class TenantGroupNestedSerializer(TenantGroupSerializer):
# Tenants # Tenants
# #
class TenantSerializer(serializers.ModelSerializer): class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer):
group = TenantGroupNestedSerializer() group = TenantGroupNestedSerializer()
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug', 'group', 'comments'] fields = ['id', 'name', 'slug', 'group', 'comments', 'custom_fields']
class TenantNestedSerializer(TenantSerializer): class TenantNestedSerializer(TenantSerializer):

View File

@ -3,6 +3,7 @@ from rest_framework import generics
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from tenancy.filters import TenantFilter from tenancy.filters import TenantFilter
from extras.api.views import CustomFieldModelAPIView
from . import serializers from . import serializers
@ -22,18 +23,18 @@ class TenantGroupDetailView(generics.RetrieveAPIView):
serializer_class = serializers.TenantGroupSerializer serializer_class = serializers.TenantGroupSerializer
class TenantListView(generics.ListAPIView): class TenantListView(CustomFieldModelAPIView, generics.ListAPIView):
""" """
List tenants (filterable) List tenants (filterable)
""" """
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
filter_class = TenantFilter filter_class = TenantFilter
class TenantDetailView(generics.RetrieveAPIView): class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
""" """
Retrieve a single tenant Retrieve a single tenant
""" """
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer

View File

@ -2,10 +2,11 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
class TenantFilter(django_filters.FilterSet): class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',
label='Search', label='Search',

View File

@ -1,9 +1,8 @@
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from utilities.forms import ( from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField, from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField
)
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
@ -48,7 +47,7 @@ class TenantGroupForm(forms.ModelForm, BootstrapMixin):
# Tenants # Tenants
# #
class TenantForm(forms.ModelForm, BootstrapMixin): class TenantForm(BootstrapMixin, CustomFieldForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
@ -70,7 +69,7 @@ class TenantImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=TenantFromCSVForm) csv = CSVDataField(csv_form=TenantFromCSVForm)
class TenantBulkEditForm(forms.Form, BootstrapMixin): class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group')
@ -80,6 +79,7 @@ def tenant_group_choices():
return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices] return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices]
class TenantFilterForm(forms.Form, BootstrapMixin): class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Tenant
group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices, group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -1,6 +1,8 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -21,7 +23,7 @@ class TenantGroup(models.Model):
return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
class Tenant(CreatedUpdatedModel): class Tenant(CreatedUpdatedModel, CustomFieldModel):
""" """
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department. department.
@ -31,6 +33,7 @@ class Tenant(CreatedUpdatedModel):
group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL) group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL)
description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)") description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta: class Meta:
ordering = ['group', 'name'] ordering = ['group', 'name']

View File

@ -107,16 +107,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'tenancy/tenant_bulk_edit.html' template_name = 'tenancy/tenant_bulk_edit.html'
default_redirect_url = 'tenancy:tenant_list' default_redirect_url = 'tenancy:tenant_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['group'] == 0:
fields_to_update['group'] = None
elif form.cleaned_data['group']:
fields_to_update['group'] = form.cleaned_data['group']
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenant' permission_required = 'tenancy.delete_tenant'

View File

@ -14,6 +14,16 @@ def render_field(field):
} }
@register.inclusion_tag('utilities/render_custom_fields.html')
def render_custom_fields(form):
"""
Render all custom fields in a form
"""
return {
'form': form,
}
@register.inclusion_tag('utilities/render_form.html') @register.inclusion_tag('utilities/render_form.html')
def render_form(form): def render_form(form):
""" """

View File

@ -7,7 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
@ -15,7 +15,8 @@ from django.utils.decorators import method_decorator
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.views.generic import View from django.views.generic import View
from extras.models import ExportTemplate, UserAction from extras.forms import CustomFieldForm
from extras.models import CustomFieldValue, ExportTemplate, UserAction
from .error_handlers import handle_protectederror from .error_handlers import handle_protectederror
from .forms import ConfirmationForm from .forms import ConfirmationForm
@ -135,6 +136,8 @@ class ObjectEditView(View):
obj = form.save(commit=False) obj = form.save(commit=False)
obj_created = not obj.pk obj_created = not obj.pk
obj.save() obj.save()
if isinstance(form, CustomFieldForm):
form.save_custom_fields()
msg = u'Created ' if obj_created else u'Modified ' msg = u'Created ' if obj_created else u'Modified '
msg += self.model._meta.verbose_name msg += self.model._meta.verbose_name
@ -274,14 +277,29 @@ class BulkEditView(View):
redirect_url = reverse(self.default_redirect_url) redirect_url = reverse(self.default_redirect_url)
if request.POST.get('_all'): if request.POST.get('_all'):
pk_list = [x for x in request.POST.get('pk_all').split(',') if x] pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else: else:
pk_list = request.POST.getlist('pk') pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST: if '_apply' in request.POST:
form = self.form(request.POST) if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, request.POST)
else:
form = self.form(request.POST)
if form.is_valid(): if form.is_valid():
updated_count = self.update_objects(pk_list, form)
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
# Update objects
updated_count = self.update_objects(pk_list, form, standard_fields)
# Update custom fields for objects
if custom_fields:
objs_updated = self.update_custom_fields(pk_list, form, custom_fields)
if objs_updated and not updated_count:
updated_count = objs_updated
if updated_count: if updated_count:
msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
messages.success(self.request, msg) messages.success(self.request, msg)
@ -289,7 +307,10 @@ class BulkEditView(View):
return redirect(redirect_url) return redirect(redirect_url)
else: else:
form = self.form(initial={'pk': pk_list}) if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, initial={'pk': pk_list})
else:
form = self.form(initial={'pk': pk_list})
selected_objects = self.cls.objects.filter(pk__in=pk_list) selected_objects = self.cls.objects.filter(pk__in=pk_list)
if not selected_objects: if not selected_objects:
@ -302,11 +323,55 @@ class BulkEditView(View):
'cancel_url': redirect_url, 'cancel_url': redirect_url,
}) })
def update_objects(self, obj_list, form): def update_objects(self, pk_list, form, fields):
""" fields_to_update = {}
This method provides the update logic (must be overridden by subclasses).
""" for name in fields:
raise NotImplementedError() # Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
fields_to_update[name] = None
elif form.cleaned_data[name]:
fields_to_update[name] = form.cleaned_data[name]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
def update_custom_fields(self, pk_list, form, fields):
obj_type = ContentType.objects.get_for_model(self.cls)
objs_updated = False
for name in fields:
if form.cleaned_data[name] not in [None, u'']:
field = form.fields[name].model
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
serialized_value = field.serialize_value(None)
else:
serialized_value = field.serialize_value(form.cleaned_data[name])
# Gather any pre-existing CustomFieldValues for the objects being edited.
existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
# Determine which objects have an existing CFV to update and which need a new CFV created.
update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
create_list = list(set(pk_list) - set(update_list))
# Creating/updating CFVs
if serialized_value:
existing_cfvs.update(serialized_value=serialized_value)
CustomFieldValue.objects.bulk_create([
CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
for pk in create_list
])
# Deleting CFVs
else:
existing_cfvs.delete()
objs_updated = True
return len(pk_list) if objs_updated else 0
class BulkDeleteView(View): class BulkDeleteView(View):