Browse Source

Minimal implemtnation of custom fields

Jeremy Stretch 8 years ago
parent
commit
6cdb62b67e

+ 3 - 2
netbox/circuits/forms.py

@@ -2,6 +2,7 @@ from django import forms
 from django.db.models import Count
 
 from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
+from extras.forms import CustomFieldForm
 from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -15,7 +16,7 @@ from .models import Circuit, CircuitType, Provider
 # Providers
 #
 
-class ProviderForm(forms.ModelForm, BootstrapMixin):
+class ProviderForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField()
     comments = CommentField()
 
@@ -82,7 +83,7 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
 # Circuits
 #
 
-class CircuitForm(forms.ModelForm, BootstrapMixin):
+class CircuitForm(BootstrapMixin, CustomFieldForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
                                   widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',

+ 3 - 2
netbox/circuits/models.py

@@ -3,11 +3,12 @@ from django.db import models
 
 from dcim.fields import ASNField
 from dcim.models import Site, Interface
+from extras.models import CustomFieldModel
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
 
-class Provider(CreatedUpdatedModel):
+class Provider(CreatedUpdatedModel, CustomFieldModel):
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     stores information pertinent to the user's relationship with the Provider.
@@ -58,7 +59,7 @@ class CircuitType(models.Model):
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
 
 
-class Circuit(CreatedUpdatedModel):
+class Circuit(CreatedUpdatedModel, CustomFieldModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device

+ 2 - 2
netbox/dcim/forms.py

@@ -165,7 +165,7 @@ class RackRoleForm(forms.ModelForm, BootstrapMixin):
 # Racks
 #
 
-class RackForm(forms.ModelForm, BootstrapMixin):
+class RackForm(BootstrapMixin, CustomFieldForm):
     group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
         api_url='/api/dcim/rack-groups/?site_id={{site}}',
     ))
@@ -405,7 +405,7 @@ class PlatformForm(forms.ModelForm, BootstrapMixin):
 # Devices
 #
 
-class DeviceForm(forms.ModelForm, BootstrapMixin):
+class DeviceForm(BootstrapMixin, CustomFieldForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
         api_url='/api/dcim/racks/?site_id={{site}}',

+ 5 - 3
netbox/dcim/models.py

@@ -1,12 +1,14 @@
 from collections import OrderedDict
 
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 
+from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from utilities.fields import NullableCharField
@@ -213,7 +215,7 @@ class SiteManager(NaturalOrderByManager):
         return self.natural_order_by('name')
 
 
-class Site(CreatedUpdatedModel):
+class Site(CreatedUpdatedModel, CustomFieldModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@@ -320,7 +322,7 @@ class RackManager(NaturalOrderByManager):
         return self.natural_order_by('site__name', 'name')
 
 
-class Rack(CreatedUpdatedModel):
+class Rack(CreatedUpdatedModel, CustomFieldModel):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -719,7 +721,7 @@ class DeviceManager(NaturalOrderByManager):
         return self.natural_order_by('name')
 
 
-class Device(CreatedUpdatedModel):
+class Device(CreatedUpdatedModel, CustomFieldModel):
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.

+ 4 - 1
netbox/extras/admin.py

@@ -10,7 +10,10 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
 @admin.register(CustomField)
 class CustomFieldAdmin(admin.ModelAdmin):
     inlines = [CustomFieldChoiceAdmin]
-    list_display = ['name', 'type', 'required', 'default', 'description']
+    list_display = ['name', 'models', 'type', 'required', 'default', 'description']
+
+    def models(self, obj):
+        return ', '.join([ct.name for ct in obj.obj_type.all()])
 
 
 @admin.register(Graph)

+ 50 - 13
netbox/extras/forms.py

@@ -1,42 +1,38 @@
-import six
-
 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_TEXT, CustomField
+from .models import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CustomField, CustomFieldValue
 
 
 class CustomFieldForm(forms.ModelForm):
-    test_field = forms.IntegerField(widget=forms.HiddenInput())
-
     custom_fields = []
 
     def __init__(self, *args, **kwargs):
 
         super(CustomFieldForm, self).__init__(*args, **kwargs)
 
-        # Find all CustomFields for this model
-        model = self._meta.model
-        custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))
+        obj_type = ContentType.objects.get_for_model(self._meta.model)
 
+        # Find all CustomFields for this model
+        custom_fields = CustomField.objects.filter(obj_type=obj_type)
         for cf in custom_fields:
 
             field_name = 'cf_{}'.format(str(cf.name))
 
             # Integer
             if cf.type == CF_TYPE_INTEGER:
-                field = forms.IntegerField(blank=not cf.required)
+                field = forms.IntegerField(required=cf.required, initial=cf.default)
 
             # Boolean
             elif cf.type == CF_TYPE_BOOLEAN:
                 if cf.required:
-                    field = forms.BooleanField(required=False)
+                    field = forms.BooleanField(required=False, initial=bool(cf.default))
                 else:
-                    field = forms.NullBooleanField(required=False)
+                    field = forms.NullBooleanField(required=False, initial=bool(cf.default))
 
             # Date
             elif cf.type == CF_TYPE_DATE:
-                field = forms.DateField(blank=not cf.required)
+                field = forms.DateField(required=cf.required, initial=cf.default)
 
             # Select
             elif cf.type == CF_TYPE_SELECT:
@@ -44,9 +40,50 @@ class CustomFieldForm(forms.ModelForm):
 
             # Text
             else:
-                field = forms.CharField(max_length=100, blank=not cf.required)
+                field = forms.CharField(max_length=100, required=cf.required, initial=cf.default)
 
+            field.model = cf
             field.label = cf.label if cf.label else cf.name
             field.help_text = cf.description
             self.fields[field_name] = field
             self.custom_fields.append(field_name)
+
+        # If editing an existing object, initialize values for all custom fields
+        if self.instance.pk:
+            existing_values = CustomFieldValue.objects.filter(obj_type=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):
+
+        if self.instance.pk:
+            obj_type = ContentType.objects.get_for_model(self.instance)
+
+            for field_name in self.custom_fields:
+
+                try:
+                    cfv = CustomFieldValue.objects.get(field=self.fields[field_name].model, obj_type=obj_type,
+                                                       obj_id=self.instance.pk)
+                except CustomFieldValue.DoesNotExist:
+                    cfv = CustomFieldValue(
+                        field=self.fields[field_name].model,
+                        obj_type=obj_type,
+                        obj_id=self.instance.pk
+                    )
+                if cfv.pk and self.cleaned_data[field_name] is None:
+                    cfv.delete()
+                elif self.cleaned_data[field_name] is not None:
+                    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

+ 9 - 5
netbox/extras/migrations/0002_custom_fields.py

@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Generated by Django 1.10 on 2016-08-12 19:52
+# Generated by Django 1.10 on 2016-08-15 19:18
 from __future__ import unicode_literals
 
 from django.db import migrations, models
@@ -20,11 +20,11 @@ class Migration(migrations.Migration):
                 ('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'Selection')], default=100)),
                 ('name', models.CharField(max_length=50, unique=True)),
-                ('label', models.CharField(help_text=b'Name of the field as displayed to users', max_length=50)),
+                ('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'This field is required when creating new objects')),
-                ('default', models.CharField(blank=True, help_text=b'Default value for the field', max_length=100)),
-                ('obj_type', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')),
+                ('required', models.BooleanField(default=False, help_text=b'Determines whether this field is required when creating new objects or editing an existing object.')),
+                ('default', models.CharField(blank=True, help_text=b'Default value for the field. N/A for selection fields.', max_length=100)),
+                ('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': ['name'],
@@ -58,6 +58,10 @@ class Migration(migrations.Migration):
             },
         ),
         migrations.AlterUniqueTogether(
+            name='customfieldvalue',
+            unique_together=set([('field', 'obj_type', 'obj_id')]),
+        ),
+        migrations.AlterUniqueTogether(
             name='customfieldchoice',
             unique_together=set([('field', 'value')]),
         ),

+ 35 - 12
netbox/extras/models.py

@@ -7,9 +7,8 @@ from django.http import HttpResponse
 from django.template import Template, Context
 from django.utils.safestring import mark_safe
 
-from dcim.models import Site
-
 
+# NOTE: Any model added here MUST have a GenericRelation defined for CustomField
 CUSTOMFIELD_MODELS = (
     'site', 'rack', 'device',                               # DCIM
     'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf',      # IPAM
@@ -62,21 +61,42 @@ 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 {field: values_dict.get(field.pk) for field in fields}
+        else:
+            return {field: None for field in fields}
+
+
 class CustomField(models.Model):
-    obj_type = models.ManyToManyField(ContentType, related_name='custom_fields',
-                                      limit_choices_to={'model__in': CUSTOMFIELD_MODELS})
+    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")
+    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="This field is required when creating new objects")
-    default = models.CharField(max_length=100, blank=True, help_text="Default value for the field")
+    required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
+                                                            "new objects or editing an existing object.")
+    default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. N/A for selection "
+                                                                     "fields.")
 
     class Meta:
         ordering = ['name']
 
     def __unicode__(self):
-        return self.label or self.name
+        return self.label or self.name.capitalize()
 
 
 class CustomFieldValue(models.Model):
@@ -90,9 +110,10 @@ class CustomFieldValue(models.Model):
 
     class Meta:
         ordering = ['obj_type', 'obj_id']
+        unique_together = ['field', 'obj_type', 'obj_id']
 
     def __unicode__(self):
-        return self.value
+        return '{} {}'.format(self.obj, self.field)
 
     @property
     def value(self):
@@ -103,17 +124,19 @@ class CustomFieldValue(models.Model):
         if self.field.type == CF_TYPE_DATE:
             return self.val_date
         if self.field.type == CF_TYPE_SELECT:
-            return CustomFieldChoice.objects.get(pk=self.val_int)
+            return CustomFieldChoice.objects.get(pk=self.val_int) if self.val_int else None
         return self.val_char
 
     @value.setter
     def value(self, value):
-        if self.field.type in [CF_TYPE_INTEGER, CF_TYPE_SELECT]:
+        if self.field.type == CF_TYPE_INTEGER:
             self.val_int = value
         elif self.field.type == CF_TYPE_BOOLEAN:
             self.val_int = bool(value) if value else None
         elif self.field.type == CF_TYPE_DATE:
             self.val_date = value
+        elif self.field.type == CF_TYPE_SELECT:
+            self.val_int = value.id
         else:
             self.val_char = value
 
@@ -195,7 +218,7 @@ class ExportTemplate(models.Model):
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    site = models.ForeignKey(Site, related_name='topology_maps', blank=True, null=True)
+    site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
     device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions,"
                                                  "one per line. Each line will result in a new tier of the drawing. "
                                                  "Separate multiple regexes on a line using commas. Devices will be "

+ 6 - 5
netbox/ipam/forms.py

@@ -4,6 +4,7 @@ from django import forms
 from django.db.models import Count
 
 from dcim.models import Site, Device, Interface
+from extras.forms import CustomFieldForm
 from tenancy.forms import bulkedit_tenant_choices
 from tenancy.models import Tenant
 from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
@@ -33,7 +34,7 @@ def bulkedit_vrf_choices():
 # VRFs
 #
 
-class VRFForm(forms.ModelForm, BootstrapMixin):
+class VRFForm(BootstrapMixin, CustomFieldForm):
 
     class Meta:
         model = VRF
@@ -91,7 +92,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
 # Aggregates
 #
 
-class AggregateForm(forms.ModelForm, BootstrapMixin):
+class AggregateForm(BootstrapMixin, CustomFieldForm):
 
     class Meta:
         model = Aggregate
@@ -149,7 +150,7 @@ class RoleForm(forms.ModelForm, BootstrapMixin):
 # Prefixes
 #
 
-class PrefixForm(forms.ModelForm, BootstrapMixin):
+class PrefixForm(BootstrapMixin, CustomFieldForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
                                   widget=forms.Select(attrs={'filter-for': 'vlan'}))
     vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
@@ -309,7 +310,7 @@ class PrefixFilterForm(forms.Form, BootstrapMixin):
 # IP addresses
 #
 
-class IPAddressForm(forms.ModelForm, BootstrapMixin):
+class IPAddressForm(BootstrapMixin, CustomFieldForm):
     nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
                                       widget=forms.Select(attrs={'filter-for': 'nat_device'}))
     nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
@@ -478,7 +479,7 @@ class VLANGroupFilterForm(forms.Form, BootstrapMixin):
 # VLANs
 #
 
-class VLANForm(forms.ModelForm, BootstrapMixin):
+class VLANForm(BootstrapMixin, CustomFieldForm):
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
         api_url='/api/ipam/vlan-groups/?site_id={{site}}',
     ))

+ 6 - 5
netbox/ipam/models.py

@@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 
 from dcim.models import Interface
+from extras.models import CustomFieldModel
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
@@ -39,7 +40,7 @@ STATUS_CHOICE_CLASSES = {
 }
 
 
-class VRF(CreatedUpdatedModel):
+class VRF(CreatedUpdatedModel, CustomFieldModel):
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
@@ -93,7 +94,7 @@ class RIR(models.Model):
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
 
 
-class Aggregate(CreatedUpdatedModel):
+class Aggregate(CreatedUpdatedModel, CustomFieldModel):
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -222,7 +223,7 @@ class PrefixQuerySet(models.QuerySet):
         return filter(lambda p: p.depth <= limit, queryset)
 
 
-class Prefix(CreatedUpdatedModel):
+class Prefix(CreatedUpdatedModel, CustomFieldModel):
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -295,7 +296,7 @@ class Prefix(CreatedUpdatedModel):
         return STATUS_CHOICE_CLASSES[self.status]
 
 
-class IPAddress(CreatedUpdatedModel):
+class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
@@ -398,7 +399,7 @@ class VLANGroup(models.Model):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
 
 
-class VLAN(CreatedUpdatedModel):
+class VLAN(CreatedUpdatedModel, CustomFieldModel):
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
     to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,

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

@@ -112,6 +112,9 @@
                 </tr>
             </table>
         </div>
+        {% with circuit.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
 	</div>
 	<div class="col-md-6">
         <div class="panel panel-default">

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

@@ -23,6 +23,14 @@
             {% render_field form.commit_rate %}
         </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-heading"><strong>Termination</strong></div>
         <div class="panel-body">

+ 3 - 0
netbox/templates/circuits/provider.html

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

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

@@ -19,6 +19,14 @@
             {% render_field form.admin_contact %}
         </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-heading"><strong>Comments</strong></div>
         <div class="panel-body">

+ 3 - 0
netbox/templates/dcim/device.html

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

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

@@ -63,6 +63,14 @@
             {% endif %}
         </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-heading"><strong>Comments</strong></div>
         <div class="panel-body">

+ 3 - 0
netbox/templates/dcim/rack.html

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

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

@@ -16,6 +16,14 @@
             {% render_field form.u_height %}
         </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-heading"><strong>Comments</strong></div>
         <div class="panel-body">

+ 3 - 0
netbox/templates/dcim/site.html

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

+ 7 - 5
netbox/templates/dcim/site_edit.html

@@ -14,12 +14,14 @@
             {% render_field form.shipping_address %}
         </div>
     </div>
-    <div class="panel panel-default">
-        <div class="panel-heading"><strong>Custom Fields</strong></div>
-        <div class="panel-body">
-            {% render_custom_fields form %}
+    {% 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>
-    </div>
+    {% endif %}
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-body">

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

@@ -0,0 +1,23 @@
+{% 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 %}
+                            {{ 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 %}

+ 5 - 0
netbox/templates/ipam/aggregate.html

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

+ 3 - 0
netbox/templates/ipam/ipaddress.html

@@ -129,6 +129,9 @@
                 </tr>
             </table>
         </div>
+        {% with ipaddress.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
 	</div>
 	<div class="col-md-6">
         {% with heading='Parent Prefixes' %}

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

@@ -51,6 +51,14 @@
             {% render_field form.nat_inside %}
         </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 %}
 
 {% block javascript %}

+ 3 - 0
netbox/templates/ipam/prefix.html

@@ -109,6 +109,9 @@
                 </tr>
             </table>
         </div>
+        {% with prefix.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
 	</div>
 	<div class="col-md-7">
         {% if duplicate_prefix_table.rows %}

+ 3 - 0
netbox/templates/ipam/vlan.html

@@ -118,6 +118,9 @@
                 </tr>
 		    </table>
         </div>
+        {% with vlan.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
 	</div>
 	<div class="col-md-6">
         <div class="panel panel-default">

+ 3 - 0
netbox/templates/ipam/vrf.html

@@ -90,6 +90,9 @@
                 </tr>
 		    </table>
         </div>
+        {% with vrf.custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
 	</div>
 	<div class="col-md-6">
         <div class="panel panel-default">

+ 3 - 0
netbox/templates/tenancy/tenant.html

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

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

@@ -12,6 +12,14 @@
             {% 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 %}
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-body">

+ 3 - 4
netbox/tenancy/forms.py

@@ -1,9 +1,8 @@
 from django import forms
 from django.db.models import Count
 
-from utilities.forms import (
-    BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField,
-)
+from extras.forms import CustomFieldForm
+from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField
 
 from .models import Tenant, TenantGroup
 
@@ -48,7 +47,7 @@ class TenantGroupForm(forms.ModelForm, BootstrapMixin):
 # Tenants
 #
 
-class TenantForm(forms.ModelForm, BootstrapMixin):
+class TenantForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField()
     comments = CommentField()
 

+ 2 - 1
netbox/tenancy/models.py

@@ -1,6 +1,7 @@
 from django.core.urlresolvers import reverse
 from django.db import models
 
+from extras.models import CustomFieldModel
 from utilities.models import CreatedUpdatedModel
 
 
@@ -21,7 +22,7 @@ class TenantGroup(models.Model):
         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
     department.

+ 3 - 0
netbox/utilities/views.py

@@ -15,6 +15,7 @@ from django.utils.decorators import method_decorator
 from django.utils.http import is_safe_url
 from django.views.generic import View
 
+from extras.forms import CustomFieldForm
 from extras.models import ExportTemplate, UserAction
 
 from .error_handlers import handle_protectederror
@@ -135,6 +136,8 @@ class ObjectEditView(View):
             obj = form.save(commit=False)
             obj_created = not obj.pk
             obj.save()
+            if isinstance(form, CustomFieldForm):
+                form.save_custom_fields()
 
             msg = u'Created ' if obj_created else u'Modified '
             msg += self.model._meta.verbose_name