Просмотр исходного кода

Merge pull request #550 from digitalocean/develop

Release v1.6.0
Jeremy Stretch 9 лет назад
Родитель
Сommit
aeec678ce9
83 измененных файлов с 1349 добавлено и 562 удалено
  1. 12 0
      .travis.yml
  2. 7 20
      Dockerfile
  3. 28 1
      docs/data-model/extras.md
  4. 6 4
      netbox/circuits/api/serializers.py
  5. 11 8
      netbox/circuits/api/views.py
  6. 3 2
      netbox/circuits/filters.py
  7. 9 6
      netbox/circuits/forms.py
  8. 6 2
      netbox/circuits/models.py
  9. 0 22
      netbox/circuits/views.py
  10. 8 7
      netbox/dcim/api/serializers.py
  11. 27 15
      netbox/dcim/api/views.py
  12. 5 4
      netbox/dcim/filters.py
  13. 13 9
      netbox/dcim/forms.py
  14. 25 0
      netbox/dcim/migrations/0019_new_iface_form_factors.py
  15. 68 27
      netbox/dcim/models.py
  16. 7 2
      netbox/dcim/tests/test_apis.py
  17. 0 50
      netbox/dcim/views.py
  18. 31 1
      netbox/extras/admin.py
  19. 39 1
      netbox/extras/api/serializers.py
  20. 19 2
      netbox/extras/api/views.py
  21. 33 0
      netbox/extras/filters.py
  22. 160 0
      netbox/extras/forms.py
  23. 68 0
      netbox/extras/migrations/0002_custom_fields.py
  24. 154 6
      netbox/extras/models.py
  25. 0 0
      netbox/extras/tests/__init__.py
  26. 97 0
      netbox/extras/tests/test_customfields.py
  27. 14 10
      netbox/ipam/api/serializers.py
  28. 25 20
      netbox/ipam/api/views.py
  29. 6 5
      netbox/ipam/filters.py
  30. 29 22
      netbox/ipam/forms.py
  31. 14 5
      netbox/ipam/models.py
  32. 4 63
      netbox/ipam/views.py
  33. 4 1
      netbox/netbox/settings.py
  34. 3 3
      netbox/project-static/css/base.css
  35. 0 55
      netbox/project-static/js/forms.js
  36. 7 4
      netbox/project-static/js/secrets.js
  37. 6 4
      netbox/secrets/forms.py
  38. 0 9
      netbox/secrets/views.py
  39. 6 1
      netbox/templates/_base.html
  40. 3 10
      netbox/templates/circuits/circuit.html
  41. 8 0
      netbox/templates/circuits/circuit_edit.html
  42. 4 8
      netbox/templates/circuits/provider.html
  43. 8 0
      netbox/templates/circuits/provider_edit.html
  44. 4 8
      netbox/templates/dcim/device.html
  45. 8 0
      netbox/templates/dcim/device_edit.html
  46. 1 1
      netbox/templates/dcim/device_import.html
  47. 4 8
      netbox/templates/dcim/rack.html
  48. 8 0
      netbox/templates/dcim/rack_edit.html
  49. 6 1
      netbox/templates/dcim/rack_import.html
  50. 4 8
      netbox/templates/dcim/site.html
  51. 1 1
      netbox/templates/dcim/site_bulk_edit.html
  52. 8 0
      netbox/templates/dcim/site_edit.html
  53. 3 0
      netbox/templates/inc/created_updated.html
  54. 29 0
      netbox/templates/inc/custom_fields_panel.html
  55. 28 23
      netbox/templates/inc/filter_panel.html
  56. 6 8
      netbox/templates/ipam/aggregate.html
  57. 22 0
      netbox/templates/ipam/aggregate_edit.html
  58. 1 8
      netbox/templates/ipam/ipaddress.html
  59. 8 0
      netbox/templates/ipam/ipaddress_edit.html
  60. 2 8
      netbox/templates/ipam/prefix.html
  61. 26 0
      netbox/templates/ipam/prefix_edit.html
  62. 1 8
      netbox/templates/ipam/vlan.html
  63. 26 0
      netbox/templates/ipam/vlan_edit.html
  64. 1 8
      netbox/templates/ipam/vrf.html
  65. 23 0
      netbox/templates/ipam/vrf_edit.html
  66. 1 8
      netbox/templates/secrets/secret.html
  67. 1 1
      netbox/templates/secrets/secret_edit.html
  68. 1 1
      netbox/templates/secrets/secret_import.html
  69. 4 8
      netbox/templates/tenancy/tenant.html
  70. 8 0
      netbox/templates/tenancy/tenant_edit.html
  71. 1 0
      netbox/templates/users/userkey.html
  72. 0 3
      netbox/templates/utilities/bulk_edit_form.html
  73. 7 0
      netbox/templates/utilities/render_custom_fields.html
  74. 3 2
      netbox/tenancy/api/serializers.py
  75. 5 4
      netbox/tenancy/api/views.py
  76. 2 1
      netbox/tenancy/filters.py
  77. 6 6
      netbox/tenancy/forms.py
  78. 4 1
      netbox/tenancy/models.py
  79. 0 10
      netbox/tenancy/views.py
  80. 1 1
      netbox/utilities/forms.py
  81. 10 0
      netbox/utilities/templatetags/form_helpers.py
  82. 77 17
      netbox/utilities/views.py
  83. 21 0
      scripts/docker-build.sh

+ 12 - 0
.travis.yml

@@ -1,3 +1,11 @@
+sudo: required
+
+services:
+  - docker
+
+env:
+  - DOCKER_TAG=$TRAVIS_TAG
+
 language: python
 language: python
 python:
 python:
   - "2.7"
   - "2.7"
@@ -6,3 +14,7 @@ install:
   - pip install pep8
   - pip install pep8
 script:
 script:
   - ./scripts/cibuild.sh
   - ./scripts/cibuild.sh
+after_success:
+  - if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
+    ./scripts/docker-build.sh;
+    fi

+ 7 - 20
Dockerfile

@@ -1,24 +1,11 @@
-FROM ubuntu:14.04
+FROM python:2.7-wheezy
 
 
-RUN apt-get update && apt-get install -y \
-	python2.7 \
-	python-dev \
-	git \
-	python-pip \
-	libxml2-dev \
-	libxslt1-dev \
-	libffi-dev \
-	graphviz \
-	libpq-dev \
-	build-essential \
-	gunicorn \
-	--no-install-recommends \
-	&& rm -rf /var/lib/apt/lists/* \
-	&& mkdir -p /opt/netbox \
-	&& cd /opt/netbox \
-	&& git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \
-	&& pip install -r requirements.txt \
-	&& apt-get purge -y --auto-remove git build-essential
+WORKDIR /opt/netbox
+
+ARG BRANCH=master
+ARG URL=https://github.com/digitalocean/netbox.git
+RUN git clone --depth 1 $URL -b $BRANCH .  && \
+	pip install gunicorn==17.5 && pip install -r requirements.txt
 
 
 ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
 ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
 ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
 ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py

+ 28 - 1
docs/data-model/extras.md

@@ -1,4 +1,31 @@
-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 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 require 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 should 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 the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object.
+
+When editing multiple objects, custom field values are saved in bulk. 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
 
 

+ 6 - 4
netbox/circuits/api/serializers.py

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

+ 11 - 8
netbox/circuits/api/views.py

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

+ 3 - 2
netbox/circuits/filters.py

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

+ 9 - 6
netbox/circuits/forms.py

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

+ 6 - 2
netbox/circuits/models.py

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

+ 0 - 22
netbox/circuits/views.py

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

+ 8 - 7
netbox/dcim/api/serializers.py

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

+ 27 - 15
netbox/dcim/api/views.py

@@ -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 . import serializers
+from extras.api.views import CustomFieldModelAPIView
 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': [],

+ 5 - 4
netbox/dcim/filters.py

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

+ 13 - 9
netbox/dcim/forms.py

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

+ 25 - 0
netbox/dcim/migrations/0019_new_iface_form_factors.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-13 15:20
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0018_device_add_asset_tag'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
+        ),
+    ]

+ 68 - 27
netbox/dcim/models.py

@@ -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
@@ -74,23 +77,39 @@ ROLE_COLOR_CHOICES = [
     [COLOR_GRAY3, 'Dark Gray'],
     [COLOR_GRAY3, 'Dark Gray'],
 ]
 ]
 
 
+# Virtual
 IFACE_FF_VIRTUAL = 0
 IFACE_FF_VIRTUAL = 0
-IFACE_FF_100M_COPPER = 800
-IFACE_FF_1GE_COPPER = 1000
-IFACE_FF_GBIC = 1050
-IFACE_FF_SFP = 1100
-IFACE_FF_10GE_COPPER = 1150
-IFACE_FF_SFP_PLUS = 1200
-IFACE_FF_XFP = 1300
-IFACE_FF_QSFP_PLUS = 1400
-IFACE_FF_CFP = 1500
-IFACE_FF_QSFP28 = 1600
+# Ethernet
+IFACE_FF_100ME_FIXED = 800
+IFACE_FF_1GE_FIXED = 1000
+IFACE_FF_1GE_GBIC = 1050
+IFACE_FF_1GE_SFP = 1100
+IFACE_FF_10GE_FIXED = 1150
+IFACE_FF_10GE_SFP_PLUS = 1200
+IFACE_FF_10GE_XFP = 1300
+IFACE_FF_10GE_XENPAK = 1310
+IFACE_FF_10GE_X2 = 1320
+IFACE_FF_25GE_SFP28 = 1350
+IFACE_FF_40GE_QSFP_PLUS = 1400
+IFACE_FF_100GE_CFP = 1500
+IFACE_FF_100GE_QSFP28 = 1600
+# Fibrechannel
+IFACE_FF_1GFC_SFP = 3010
+IFACE_FF_2GFC_SFP = 3020
+IFACE_FF_4GFC_SFP = 3040
+IFACE_FF_8GFC_SFP_PLUS = 3080
+IFACE_FF_16GFC_SFP_PLUS = 3160
+# Serial
 IFACE_FF_T1 = 4000
 IFACE_FF_T1 = 4000
 IFACE_FF_E1 = 4010
 IFACE_FF_E1 = 4010
 IFACE_FF_T3 = 4040
 IFACE_FF_T3 = 4040
 IFACE_FF_E3 = 4050
 IFACE_FF_E3 = 4050
+# Stacking
 IFACE_FF_STACKWISE = 5000
 IFACE_FF_STACKWISE = 5000
 IFACE_FF_STACKWISE_PLUS = 5050
 IFACE_FF_STACKWISE_PLUS = 5050
+# Other
+IFACE_FF_OTHER = 32767
+
 IFACE_FF_CHOICES = [
 IFACE_FF_CHOICES = [
     [
     [
         'Virtual interfaces',
         'Virtual interfaces',
@@ -99,23 +118,36 @@ IFACE_FF_CHOICES = [
         ]
         ]
     ],
     ],
     [
     [
-        'Ethernet',
+        'Ethernet (fixed)',
+        [
+            [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
+            [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
+            [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
+        ]
+    ],
+    [
+        'Ethernet (modular)',
         [
         [
-            [IFACE_FF_100M_COPPER, '100BASE-TX (10/100M)'],
-            [IFACE_FF_1GE_COPPER, '1000BASE-T (1GE)'],
-            [IFACE_FF_10GE_COPPER, '10GBASE-T (10GE)'],
+            [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
+            [IFACE_FF_1GE_SFP, 'SFP (1GE)'],
+            [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
+            [IFACE_FF_10GE_XFP, 'XFP (10GE)'],
+            [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
+            [IFACE_FF_10GE_X2, 'X2 (10GE)'],
+            [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
+            [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
+            [IFACE_FF_100GE_CFP, 'CFP (100GE)'],
+            [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
         ]
         ]
     ],
     ],
     [
     [
-        'Modular',
+        'FibreChannel',
         [
         [
-            [IFACE_FF_GBIC, 'GBIC (1GE)'],
-            [IFACE_FF_SFP, 'SFP (1GE)'],
-            [IFACE_FF_XFP, 'XFP (10GE)'],
-            [IFACE_FF_SFP_PLUS, 'SFP+ (10GE)'],
-            [IFACE_FF_QSFP_PLUS, 'QSFP+ (40GE)'],
-            [IFACE_FF_CFP, 'CFP (100GE)'],
-            [IFACE_FF_QSFP28, 'QSFP28 (100GE)'],
+            [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
+            [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
+            [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
+            [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
+            [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
         ]
         ]
     ],
     ],
     [
     [
@@ -134,6 +166,12 @@ IFACE_FF_CHOICES = [
             [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
             [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
         ]
         ]
     ],
     ],
+    [
+        'Other',
+        [
+            [IFACE_FF_OTHER, 'Other'],
+        ]
+    ],
 ]
 ]
 
 
 STATUS_ACTIVE = True
 STATUS_ACTIVE = True
@@ -213,7 +251,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 +264,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 +359,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 +376,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()
 
 
@@ -642,7 +682,7 @@ class InterfaceTemplate(models.Model):
     """
     """
     device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
     device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
     name = models.CharField(max_length=30)
     name = models.CharField(max_length=30)
-    form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
+    form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
     mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
 
 
     objects = InterfaceTemplateManager()
     objects = InterfaceTemplateManager()
@@ -719,7 +759,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 +790,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()
 
 
@@ -1017,7 +1058,7 @@ class Interface(models.Model):
     """
     """
     device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
     device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
     name = models.CharField(max_length=30)
     name = models.CharField(max_length=30)
-    form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
+    form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
     mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
     mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
     mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
                                     help_text="This interface is used only for out-of-band management")
                                     help_text="This interface is used only for out-of-band management")

+ 7 - 2
netbox/dcim/tests/test_apis.py

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

+ 0 - 50
netbox/dcim/views.py

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

+ 31 - 1
netbox/extras/admin.py

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

+ 39 - 1
netbox/extras/api/serializers.py

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

+ 19 - 2
netbox/extras/api/views.py

@@ -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 - 0
netbox/extras/filters.py

@@ -0,0 +1,33 @@
+import django_filters
+
+from django.contrib.contenttypes.models import ContentType
+
+from .models import CustomField
+
+
+class CustomFieldFilter(django_filters.Filter):
+    """
+    Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
+    """
+
+    def filter(self, queryset, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            custom_field_values__field__name=self.name,
+            custom_field_values__serialized_value=value,
+        )
+
+
+class CustomFieldFilterSet(django_filters.FilterSet):
+    """
+    Dynamically add a Filter for each CustomField applicable to the parent model.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
+
+        obj_type = ContentType.objects.get_for_model(self._meta.model)
+        custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
+        for cf in custom_fields:
+            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name)

+ 160 - 0
netbox/extras/forms.py

@@ -0,0 +1,160 @@
+from collections import OrderedDict
+
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from .models import (
+    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
+)
+
+
+def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
+    """
+    Retrieve all CustomFields applicable to the given ContentType
+    """
+    field_dict = OrderedDict()
+    kwargs = {'obj_type': content_type}
+    if filterable_only:
+        kwargs['is_filterable'] = True
+    custom_fields = CustomField.objects.filter(**kwargs)
+
+    for cf in custom_fields:
+        field_name = 'cf_{}'.format(str(cf.name))
+
+        # Integer
+        if cf.type == CF_TYPE_INTEGER:
+            field = forms.IntegerField(required=cf.required, initial=cf.default)
+
+        # Boolean
+        elif cf.type == CF_TYPE_BOOLEAN:
+            choices = (
+                (None, '---------'),
+                (1, 'True'),
+                (0, '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

+ 68 - 0
netbox/extras/migrations/0002_custom_fields.py

@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-08-23 20:33
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CustomField',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('type', models.PositiveSmallIntegerField(choices=[(100, b'Text'), (200, b'Integer'), (300, b'Boolean (true/false)'), (400, b'Date'), (500, b'URL'), (600, b'Selection')], default=100)),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('label', models.CharField(blank=True, help_text=b"Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)),
+                ('description', models.CharField(blank=True, max_length=100)),
+                ('required', models.BooleanField(default=False, help_text=b'Determines whether this field is required when creating new objects or editing an existing object.')),
+                ('is_filterable', models.BooleanField(default=True, help_text=b'This field can be used to filter objects.')),
+                ('default', models.CharField(blank=True, help_text=b'Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100)),
+                ('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Fields with higher weights appear lower in a form')),
+                ('obj_type', models.ManyToManyField(help_text=b'The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name=b'Object(s)')),
+            ],
+            options={
+                'ordering': ['weight', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='CustomFieldChoice',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('value', models.CharField(max_length=100)),
+                ('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Higher weights appear lower in the list')),
+                ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')),
+            ],
+            options={
+                'ordering': ['field', 'weight', 'value'],
+            },
+        ),
+        migrations.CreateModel(
+            name='CustomFieldValue',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('obj_id', models.PositiveIntegerField()),
+                ('serialized_value', models.CharField(max_length=255)),
+                ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')),
+                ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ['obj_type', 'obj_id'],
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='customfieldvalue',
+            unique_together=set([('field', 'obj_type', 'obj_id')]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='customfieldchoice',
+            unique_together=set([('field', 'value')]),
+        ),
+    ]

+ 154 - 6
netbox/extras/models.py

@@ -1,12 +1,37 @@
+from collections import OrderedDict
+from datetime import date
+
 from django.contrib.auth.models import User
 from django.contrib.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)
@@ -86,10 +234,10 @@ class ExportTemplate(models.Model):
         """
         """
         template = Template(self.template_code)
         template = Template(self.template_code)
         mime_type = 'text/plain' if not self.mime_type else self.mime_type
         mime_type = 'text/plain' if not self.mime_type else self.mime_type
-        response = HttpResponse(
-            template.render(Context(context_dict)),
-            content_type=mime_type
-        )
+        output = template.render(Context(context_dict))
+        # Replace CRLF-style line terminators
+        output = output.replace('\r\n', '\n')
+        response = HttpResponse(output, content_type=mime_type)
         if self.file_extension:
         if self.file_extension:
             filename += '.{}'.format(self.file_extension)
             filename += '.{}'.format(self.file_extension)
         response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
         response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
@@ -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 "

+ 0 - 0
netbox/extras/tests/__init__.py


+ 97 - 0
netbox/extras/tests/test_customfields.py

@@ -0,0 +1,97 @@
+from datetime import date
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from dcim.models import Site
+
+from extras.models import (
+    CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
+    CF_TYPE_SELECT, CF_TYPE_URL,
+)
+
+
+class CustomFieldTestCase(TestCase):
+
+    def setUp(self):
+
+        Site.objects.bulk_create([
+            Site(name='Site A', slug='site-a'),
+            Site(name='Site B', slug='site-b'),
+            Site(name='Site C', slug='site-c'),
+        ])
+
+    def test_simple_fields(self):
+
+        DATA = (
+            {'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
+            {'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None},
+            {'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
+            {'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
+            {'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
+            {'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
+            {'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
+        )
+
+        obj_type = ContentType.objects.get_for_model(Site)
+
+        for data in DATA:
+
+            # Create a custom field
+            cf = CustomField(type=data['field_type'], name='my_field', required=False)
+            cf.save()
+            cf.obj_type = [obj_type]
+            cf.save()
+
+            # Assign a value to the first Site
+            site = Site.objects.first()
+            cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
+            cfv.value = data['field_value']
+            cfv.save()
+
+            # Retrieve the stored value
+            cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
+            self.assertEqual(cfv.value, data['field_value'])
+
+            # Delete the stored value
+            cfv.value = data['empty_value']
+            cfv.save()
+            self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
+
+            # Delete the custom field
+            cf.delete()
+
+    def test_select_field(self):
+
+        obj_type = ContentType.objects.get_for_model(Site)
+
+        # Create a custom field
+        cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False)
+        cf.save()
+        cf.obj_type = [obj_type]
+        cf.save()
+
+        # Create some choices for the field
+        CustomFieldChoice.objects.bulk_create([
+            CustomFieldChoice(field=cf, value='Option A'),
+            CustomFieldChoice(field=cf, value='Option B'),
+            CustomFieldChoice(field=cf, value='Option C'),
+        ])
+
+        # Assign a value to the first Site
+        site = Site.objects.first()
+        cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
+        cfv.value = cf.choices.first()
+        cfv.save()
+
+        # Retrieve the stored value
+        cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
+        self.assertEqual(str(cfv.value), 'Option A')
+
+        # Delete the stored value
+        cfv.value = None
+        cfv.save()
+        self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
+
+        # Delete the custom field
+        cf.delete()

+ 14 - 10
netbox/ipam/api/serializers.py

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

+ 25 - 20
netbox/ipam/api/views.py

@@ -3,6 +3,7 @@ from rest_framework import generics
 from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
 from ipam.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

+ 6 - 5
netbox/ipam/filters.py

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

+ 29 - 22
netbox/ipam/forms.py

@@ -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
@@ -15,6 +14,11 @@ from .models import (
 
 
 FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
 FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
 FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
 FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
+IP_FAMILY_CHOICES = [
+    ('', 'All'),
+    (4, 'IPv4'),
+    (6, 'IPv6'),
+]
 
 
 
 
 def bulkedit_vrf_choices():
 def bulkedit_vrf_choices():
@@ -33,7 +37,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 +63,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 +74,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 +96,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 +121,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 +133,9 @@ 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
+    family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     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 +156,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 +258,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,10 +295,12 @@ 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',
     }))
     }))
+    family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
     vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
                                     widget=forms.SelectMultiple(attrs={'size': 6}))
                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
     tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
@@ -309,7 +318,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,27 +434,24 @@ 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')
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
 
 
-def ipaddress_family_choices():
-    return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
-
-
 def ipaddress_vrf_choices():
 def ipaddress_vrf_choices():
     vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
     vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
     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',
     }))
     }))
-    family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
+    family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
     vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
                                     widget=forms.SelectMultiple(attrs={'size': 6}))
                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
     tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
@@ -478,7 +484,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 +545,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 +582,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',

+ 14 - 5
netbox/ipam/models.py

@@ -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()
 
 
@@ -275,6 +280,7 @@ class Prefix(CreatedUpdatedModel):
         return ','.join([
         return ','.join([
             str(self.prefix),
             str(self.prefix),
             self.vrf.rd if self.vrf else '',
             self.vrf.rd if self.vrf else '',
+            self.tenant.name if self.tenant else '',
             self.site.name if self.site else '',
             self.site.name if self.site else '',
             self.get_status_display(),
             self.get_status_display(),
             self.role.name if self.role else '',
             self.role.name if self.role else '',
@@ -310,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
@@ -331,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()
 
 
@@ -378,6 +385,7 @@ class IPAddress(CreatedUpdatedModel):
         return ','.join([
         return ','.join([
             str(self.address),
             str(self.address),
             self.vrf.rd if self.vrf else '',
             self.vrf.rd if self.vrf else '',
+            self.tenant.name if self.tenant else '',
             self.device.identifier if self.device else '',
             self.device.identifier if self.device else '',
             self.interface.name if self.interface else '',
             self.interface.name if self.interface else '',
             'True' if is_primary else '',
             'True' if is_primary else '',
@@ -415,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,
@@ -435,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']

+ 4 - 63
netbox/ipam/views.py

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

+ 4 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.5.2'
+VERSION = '1.6.0'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -164,6 +164,9 @@ STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
     os.path.join(BASE_DIR, "project-static"),
 )
 )
 
 
+# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
+DATA_UPLOAD_MAX_NUMBER_FIELDS = None
+
 # Messages
 # Messages
 MESSAGE_TAGS = {
 MESSAGE_TAGS = {
     messages.ERROR: 'danger',
     messages.ERROR: 'danger',

+ 3 - 3
netbox/project-static/css/base.css

@@ -34,7 +34,8 @@ body {
 footer p {
 footer p {
     margin: 20px 0;
     margin: 20px 0;
 }
 }
-@media (max-width: 1120px) {
+
+@media (max-width: 1200px) {
     .navbar-header {
     .navbar-header {
         float: none;
         float: none;
     }
     }
@@ -54,6 +55,7 @@ footer p {
     }
     }
     .navbar-collapse.collapse {
     .navbar-collapse.collapse {
         display: none!important;
         display: none!important;
+        max-height: none;
     }
     }
     .navbar-nav {
     .navbar-nav {
         float: none!important;
         float: none!important;
@@ -84,13 +86,11 @@ th.pk, td.pk {
     width: 30px;
     width: 30px;
 }
 }
 
 
-
 /* Paginator */
 /* Paginator */
 nav ul.pagination {
 nav ul.pagination {
     margin-top: 0;
     margin-top: 0;
 }
 }
 
 
-
 /* Racks */
 /* Racks */
 div.rack_header {
 div.rack_header {
     margin-left: 36px;
     margin-left: 36px;

+ 0 - 55
netbox/project-static/js/forms.js

@@ -32,61 +32,6 @@ $(document).ready(function() {
         })
         })
     }
     }
 
 
-    // Helper select fields
-    $('select.helper-parent').change(function () {
-
-        // Resolve child field by ID specified in parent
-        var child_field = $('#id_' + $(this).attr('child'));
-
-        // Wipe out any existing options within the child field
-        child_field.empty();
-        child_field.append($("<option></option>").attr("value", "").text(""));
-
-        // If the parent has a value set, fetch a list of child options via the API and populate the child field with them
-        if ($(this).val()) {
-
-            // Construct the API request URL
-            var api_url = $(this).attr('child-source');
-            var parent_accessor = $(this).attr('parent-accessor');
-            if (parent_accessor) {
-                api_url += '?' + parent_accessor + '=' + $(this).val();
-            } else {
-                api_url += '?' + $(this).attr('name') + '_id=' + $(this).val();
-            }
-            var api_url_extra = $(this).attr('child-filter');
-            if (api_url_extra) {
-                api_url += '&' + api_url_extra;
-            }
-
-            var disabled_indicator = $(this).attr('disabled-indicator');
-            var disabled_exempt = child_field.attr('exempt');
-            var child_display = $(this).attr('child-display');
-            if (!child_display) {
-                child_display = 'name';
-            }
-
-            $.ajax({
-                url: api_url,
-                dataType: 'json',
-                success: function (response, status) {
-                    console.log(response);
-                    $.each(response, function (index, choice) {
-                        var option = $("<option></option>").attr("value", choice.id).text(choice[child_display]);
-                        if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) {
-                            option.attr("disabled", "disabled")
-                        }
-                        child_field.append(option);
-                    });
-                }
-            });
-
-        }
-
-        // Trigger change event in case the child field is the parent of another field
-        child_field.change();
-
-    });
-
     // API select widget
     // API select widget
     $('select[filter-for]').change(function () {
     $('select[filter-for]').change(function () {
 
 

+ 7 - 4
netbox/project-static/js/secrets.js

@@ -25,17 +25,20 @@ $(document).ready(function() {
     });
     });
 
 
     // Adding/editing a secret
     // Adding/editing a secret
-    $('form.requires-private-key').submit(function(event) {
+    private_key_field = $('#id_private_key');
+    private_key_field.parents('form').submit(function(event) {
+        console.log("form submitted");
         var private_key = sessionStorage.getItem('private_key');
         var private_key = sessionStorage.getItem('private_key');
         if (private_key) {
         if (private_key) {
-            $('#id_private_key').val(private_key);
-        } else {
+            private_key_field.val(private_key);
+        } else if ($('form .requires-private-key:first').val()) {
+            console.log("we need a key!");
             $('#privkey_modal').modal('show');
             $('#privkey_modal').modal('show');
             return false;
             return false;
         }
         }
     });
     });
 
 
-    // Prompt the user to enter a private RSA key for decryption
+    // Saving a private RSA key locally
     $('#submit_privkey').click(function() {
     $('#submit_privkey').click(function() {
         var private_key = $('#user_privkey').val();
         var private_key = $('#user_privkey').val();
         sessionStorage.setItem('private_key', private_key);
         sessionStorage.setItem('private_key', private_key);

+ 6 - 4
netbox/secrets/forms.py

@@ -47,8 +47,9 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
 #
 #
 
 
 class SecretForm(forms.ModelForm, BootstrapMixin):
 class SecretForm(forms.ModelForm, BootstrapMixin):
-    private_key = forms.CharField(widget=forms.HiddenInput())
-    plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext')
+    private_key = forms.CharField(required=False, widget=forms.HiddenInput())
+    plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
+                                widget=forms.TextInput(attrs={'class': 'requires-private-key'}))
     plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
     plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
 
 
     class Meta:
     class Meta:
@@ -56,7 +57,8 @@ class SecretForm(forms.ModelForm, BootstrapMixin):
         fields = ['role', 'name', 'plaintext', 'plaintext2']
         fields = ['role', 'name', 'plaintext', 'plaintext2']
 
 
     def clean(self):
     def clean(self):
-        validate_rsa_key(self.cleaned_data['private_key'])
+        if self.cleaned_data['plaintext']:
+            validate_rsa_key(self.cleaned_data['private_key'])
 
 
     def clean_plaintext2(self):
     def clean_plaintext2(self):
         plaintext = self.cleaned_data['plaintext']
         plaintext = self.cleaned_data['plaintext']
@@ -84,7 +86,7 @@ class SecretFromCSVForm(forms.ModelForm):
 
 
 class SecretImportForm(BulkImportForm, BootstrapMixin):
 class SecretImportForm(BulkImportForm, BootstrapMixin):
     private_key = forms.CharField(widget=forms.HiddenInput())
     private_key = forms.CharField(widget=forms.HiddenInput())
-    csv = CSVDataField(csv_form=SecretFromCSVForm)
+    csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
 
 
 
 
 class SecretBulkEditForm(forms.Form, BootstrapMixin):
 class SecretBulkEditForm(forms.Form, BootstrapMixin):

+ 0 - 9
netbox/secrets/views.py

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

+ 6 - 1
netbox/templates/_base.html

@@ -167,7 +167,7 @@
                             {% if perms.ipam.add_rir or perms.ipam.add_role %}
                             {% if perms.ipam.add_rir or perms.ipam.add_role %}
                                 <li class="divider"></li>
                                 <li class="divider"></li>
                             {% endif %}
                             {% endif %}
-                            <li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix/VLAN Roles</a></li>
+                            <li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix Roles</a></li>
                             {% if perms.ipam.add_role %}
                             {% if perms.ipam.add_role %}
                                 <li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
                                 <li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
                             {% endif %}
                             {% endif %}
@@ -186,6 +186,11 @@
                             {% if perms.ipam.add_vlangroup %}
                             {% if perms.ipam.add_vlangroup %}
                                 <li><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
                                 <li><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
                             {% endif %}
                             {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLAN Roles</a></li>
+                            {% if perms.ipam.add_role %}
+                                <li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
+                            {% endif %}
                         </ul>
                         </ul>
                     </li>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
                     <li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">

+ 3 - 10
netbox/templates/circuits/circuit.html

@@ -95,23 +95,16 @@
                 <tr>
                 <tr>
                     <td>Commit Rate</td>
                     <td>Commit Rate</td>
                     <td>
                     <td>
-                        {% if circuit.commit_speed %}
-                            {{ circuit.commit_speed_human }}
+                        {% if circuit.commit_rate %}
+                            {{ circuit.commit_rate_human }}
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ circuit.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ circuit.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=circuit %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 8 - 0
netbox/templates/circuits/circuit_edit.html

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

+ 4 - 8
netbox/templates/circuits/provider.html

@@ -103,16 +103,11 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ provider.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ provider.last_updated }}</td>
-                </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>
@@ -125,6 +120,7 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=provider %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 8 - 0
netbox/templates/circuits/provider_edit.html

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

+ 4 - 8
netbox/templates/dcim/device.html

@@ -79,14 +79,6 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ device.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ device.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -152,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">
@@ -306,6 +301,7 @@
                 <div class="panel-body text-muted">None found</div>
                 <div class="panel-body text-muted">None found</div>
             {% endif %}
             {% endif %}
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=device %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% if device_bays or device.device_type.is_parent_device %}
         {% if device_bays or device.device_type.is_parent_device %}

+ 8 - 0
netbox/templates/dcim/device_edit.html

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

+ 1 - 1
netbox/templates/dcim/device_import.html

@@ -83,7 +83,7 @@
 				</tr>
 				</tr>
 				<tr>
 				<tr>
 					<td>Face</td>
 					<td>Face</td>
-					<td>Rack face; front or rear (optional)</td>
+					<td>Rack face; front or rear (required if position is set)</td>
 					<td>Rear</td>
 					<td>Rear</td>
 				</tr>
 				</tr>
 			</tbody>
 			</tbody>

+ 4 - 8
netbox/templates/dcim/rack.html

@@ -130,16 +130,11 @@
                         <a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
                         <a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ rack.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ rack.last_updated }}</td>
-                </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>
@@ -187,6 +182,7 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=rack %}
 	</div>
 	</div>
     <div class="row col-md-6">
     <div class="row col-md-6">
        <div class="col-md-6 col-sm-6 col-xs-12">
        <div class="col-md-6 col-sm-6 col-xs-12">

+ 8 - 0
netbox/templates/dcim/rack_edit.html

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

+ 6 - 1
netbox/templates/dcim/rack_import.html

@@ -53,6 +53,11 @@
 					<td>Name of tenant (optional)</td>
 					<td>Name of tenant (optional)</td>
 					<td>Pied Piper</td>
 					<td>Pied Piper</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Role</td>
+					<td>Functional role (optional)</td>
+					<td>Compute</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Type</td>
 					<td>Type</td>
 					<td>Rack type (optional)</td>
 					<td>Rack type (optional)</td>
@@ -71,7 +76,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,4-post cabinet,19,42</pre>
+		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 4 - 8
netbox/templates/dcim/site.html

@@ -109,16 +109,11 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ site.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ site.last_updated }}</td>
-                </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>
@@ -131,6 +126,7 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=site %}
     </div>
     </div>
     <div class="col-md-5">
     <div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 1 - 1
netbox/templates/dcim/site_bulk_edit.html

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

+ 8 - 0
netbox/templates/dcim/site_edit.html

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

+ 3 - 0
netbox/templates/inc/created_updated.html

@@ -0,0 +1,3 @@
+<p>
+    <small class="text-muted">Created {{ obj.created }} &middot; Updated <span title="{{ obj.last_updated }}">{{ obj.last_updated|timesince }}</span> ago</small>
+</p>

+ 29 - 0
netbox/templates/inc/custom_fields_panel.html

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

+ 28 - 23
netbox/templates/inc/filter_panel.html

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

+ 6 - 8
netbox/templates/ipam/aggregate.html

@@ -77,16 +77,14 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ aggregate.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ aggregate.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=aggregate %}
+    </div>
+    <div class="col-md-6">
+        {% with aggregate.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
     </div>
     </div>
 </div>
 </div>
 <div class="row">
 <div class="row">

+ 22 - 0
netbox/templates/ipam/aggregate_edit.html

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

+ 1 - 8
netbox/templates/ipam/ipaddress.html

@@ -119,16 +119,9 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ ipaddress.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ ipaddress.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=ipaddress %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% with heading='Parent Prefixes' %}
         {% with heading='Parent Prefixes' %}

+ 8 - 0
netbox/templates/ipam/ipaddress_edit.html

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

+ 2 - 8
netbox/templates/ipam/prefix.html

@@ -99,16 +99,10 @@
                     <td>IP Addresses</td>
                     <td>IP Addresses</td>
                     <td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }}</a></td>
                     <td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }}</a></td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ prefix.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ prefix.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=prefix %}
+        <br />
 	</div>
 	</div>
 	<div class="col-md-7">
 	<div class="col-md-7">
         {% if duplicate_prefix_table.rows %}
         {% if duplicate_prefix_table.rows %}

+ 26 - 0
netbox/templates/ipam/prefix_edit.html

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

+ 1 - 8
netbox/templates/ipam/vlan.html

@@ -107,17 +107,10 @@
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
-                </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ vlan.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ vlan.last_updated }}</td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=vlan %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 26 - 0
netbox/templates/ipam/vlan_edit.html

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

+ 1 - 8
netbox/templates/ipam/vrf.html

@@ -79,17 +79,10 @@
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
-                </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ vrf.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ vrf.last_updated }}</td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=vrf %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 23 - 0
netbox/templates/ipam/vrf_edit.html

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

+ 1 - 8
netbox/templates/secrets/secret.html

@@ -56,16 +56,9 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ secret.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ secret.last_updated }}</td>
-                </tr>
             </table>
             </table>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=secret %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% if secret|decryptable_by:request.user %}
         {% if secret|decryptable_by:request.user %}

+ 1 - 1
netbox/templates/secrets/secret_edit.html

@@ -5,7 +5,7 @@
 {% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}
 {% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<form action="." method="post" class="form form-horizontal requires-private-key">
+<form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     {{ form.private_key }}
     {{ form.private_key }}
     <div class="row">
     <div class="row">

+ 1 - 1
netbox/templates/secrets/secret_import.html

@@ -17,7 +17,7 @@
                 </div>
                 </div>
             </div>
             </div>
         {% endif %}
         {% endif %}
-		<form action="." method="post" class="form requires-private-key">
+		<form action="." method="post" class="form">
 		    {% csrf_token %}
 		    {% csrf_token %}
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">

+ 4 - 8
netbox/templates/tenancy/tenant.html

@@ -63,16 +63,11 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Created</td>
-                    <td>{{ tenant.created }}</td>
-                </tr>
-                <tr>
-                    <td>Last Updated</td>
-                    <td>{{ tenant.last_updated }}</td>
-                </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>
@@ -85,6 +80,7 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+        {% include 'inc/created_updated.html' with obj=tenant %}
 	</div>
 	</div>
 	<div class="col-md-5">
 	<div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 8 - 0
netbox/templates/tenancy/tenant_edit.html

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

+ 1 - 0
netbox/templates/users/userkey.html

@@ -31,6 +31,7 @@
                     Edit user key
                     Edit user key
                 </a>
                 </a>
             </div>
             </div>
+            {% include 'inc/created_updated.html' with obj=userkey %}
         {% else %}
         {% else %}
             <p>You don't have a user key on file.</p>
             <p>You don't have a user key on file.</p>
             <p>
             <p>

+ 0 - 3
netbox/templates/utilities/bulk_edit_form.html

@@ -8,9 +8,6 @@
     {% if request.POST.redirect_url %}
     {% if request.POST.redirect_url %}
         <input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
         <input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
     {% endif %}
     {% endif %}
-    {% for hidden in form.hidden_fields %}
-        {{ hidden }}
-    {% endfor %}
     <div class="row">
     <div class="row">
         <div class="col-md-7">
         <div class="col-md-7">
             <div class="panel panel-default">
             <div class="panel panel-default">

+ 7 - 0
netbox/templates/utilities/render_custom_fields.html

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

+ 3 - 2
netbox/tenancy/api/serializers.py

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

+ 5 - 4
netbox/tenancy/api/views.py

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

+ 2 - 1
netbox/tenancy/filters.py

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

+ 6 - 6
netbox/tenancy/forms.py

@@ -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 (
-    BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField,
-)
+from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+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}))

+ 4 - 1
netbox/tenancy/models.py

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

+ 0 - 10
netbox/tenancy/views.py

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

+ 1 - 1
netbox/utilities/forms.py

@@ -130,11 +130,11 @@ class CSVDataField(forms.CharField):
         '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
         '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
     """
     """
     csv_form = None
     csv_form = None
+    widget = forms.Textarea
 
 
     def __init__(self, csv_form, *args, **kwargs):
     def __init__(self, csv_form, *args, **kwargs):
         self.csv_form = csv_form
         self.csv_form = csv_form
         self.columns = self.csv_form().fields.keys()
         self.columns = self.csv_form().fields.keys()
-        self.widget = forms.Textarea
         super(CSVDataField, self).__init__(*args, **kwargs)
         super(CSVDataField, self).__init__(*args, **kwargs)
         self.strip = False
         self.strip = False
         if not self.label:
         if not self.label:

+ 10 - 0
netbox/utilities/templatetags/form_helpers.py

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

+ 77 - 17
netbox/utilities/views.py

@@ -1,13 +1,12 @@
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
 from django.contrib import messages
 from django.contrib import messages
-from django.contrib.admin.views.decorators import staff_member_required
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ImproperlyConfigured
 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 +14,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 +135,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 +276,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 +306,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 +322,55 @@ class BulkEditView(View):
             'cancel_url': redirect_url,
             'cancel_url': redirect_url,
         })
         })
 
 
-    def update_objects(self, obj_list, form):
-        """
-        This method provides the update logic (must be overridden by subclasses).
-        """
-        raise NotImplementedError()
+    def update_objects(self, pk_list, form, fields):
+        fields_to_update = {}
+
+        for name in fields:
+            # 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):
@@ -316,10 +380,6 @@ class BulkDeleteView(View):
     template_name = 'utilities/confirm_bulk_delete.html'
     template_name = 'utilities/confirm_bulk_delete.html'
     default_redirect_url = None
     default_redirect_url = None
 
 
-    @method_decorator(staff_member_required)
-    def dispatch(self, *args, **kwargs):
-        return super(BulkDeleteView, self).dispatch(*args, **kwargs)
-
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
 
 
         # Attempt to derive parent object if a parent class has been given
         # Attempt to derive parent object if a parent class has been given

+ 21 - 0
scripts/docker-build.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+
+docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
+if [ $? -ne 0 ]; then
+  echo "docker login failed."
+  exit 1
+fi
+
+docker build -t "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" .
+if [ $? -ne 0 ]; then
+  echo "docker build failed."
+  exit 1
+fi
+
+docker push "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG"
+if [ $? -ne 0 ]; then
+  echo "docker push failed."
+  exit 1
+fi
+
+exit 0