From 6cdb62b67e6a32c8e78d17a85b9c79b181e39152 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 Aug 2016 15:24:23 -0400 Subject: [PATCH] Minimal implemtnation of custom fields --- netbox/circuits/forms.py | 5 +- netbox/circuits/models.py | 5 +- netbox/dcim/forms.py | 4 +- netbox/dcim/models.py | 8 ++- netbox/extras/admin.py | 5 +- netbox/extras/forms.py | 63 +++++++++++++++---- .../extras/migrations/0002_custom_fields.py | 14 +++-- netbox/extras/models.py | 47 ++++++++++---- netbox/ipam/forms.py | 11 ++-- netbox/ipam/models.py | 11 ++-- netbox/templates/circuits/circuit.html | 3 + netbox/templates/circuits/circuit_edit.html | 8 +++ netbox/templates/circuits/provider.html | 3 + netbox/templates/circuits/provider_edit.html | 8 +++ netbox/templates/dcim/device.html | 3 + netbox/templates/dcim/device_edit.html | 8 +++ netbox/templates/dcim/rack.html | 3 + netbox/templates/dcim/rack_edit.html | 8 +++ netbox/templates/dcim/site.html | 3 + netbox/templates/dcim/site_edit.html | 12 ++-- netbox/templates/inc/custom_fields_panel.html | 23 +++++++ netbox/templates/ipam/aggregate.html | 5 ++ netbox/templates/ipam/ipaddress.html | 3 + netbox/templates/ipam/ipaddress_edit.html | 8 +++ netbox/templates/ipam/prefix.html | 3 + netbox/templates/ipam/vlan.html | 3 + netbox/templates/ipam/vrf.html | 3 + netbox/templates/tenancy/tenant.html | 3 + netbox/templates/tenancy/tenant_edit.html | 8 +++ netbox/tenancy/forms.py | 7 +-- netbox/tenancy/models.py | 3 +- netbox/utilities/views.py | 3 + 32 files changed, 244 insertions(+), 60 deletions(-) create mode 100644 netbox/templates/inc/custom_fields_panel.html diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index a44dd763e..bc0c55e6e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,6 +2,7 @@ from django import forms from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from extras.forms import CustomFieldForm 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}}', diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 00367a27a..54afd5c29 100644 --- a/netbox/circuits/models.py +++ b/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 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c9923b6f8..2e9c67e2e 100644 --- a/netbox/dcim/forms.py +++ b/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}}', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b1c6b60b7..6af4535a1 100644 --- a/netbox/dcim/models.py +++ b/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. diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f12449aed..7d70e3c08 100644 --- a/netbox/extras/admin.py +++ b/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) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 385133dc1..e3126722b 100644 --- a/netbox/extras/forms.py +++ b/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 diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 62bba81ee..361ca1363 100644 --- a/netbox/extras/migrations/0002_custom_fields.py +++ b/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'], @@ -57,6 +57,10 @@ class Migration(migrations.Migration): 'ordering': ['obj_type', 'obj_id'], }, ), + migrations.AlterUniqueTogether( + name='customfieldvalue', + unique_together=set([('field', 'obj_type', 'obj_id')]), + ), migrations.AlterUniqueTogether( name='customfieldchoice', unique_together=set([('field', 'value')]), diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a7390dec4..ba51ac58b 100644 --- a/netbox/extras/models.py +++ b/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 " diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 00374ef36..4e64907f6 100644 --- a/netbox/ipam/forms.py +++ b/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}}', )) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bd49feef1..feb289940 100644 --- a/netbox/ipam/models.py +++ b/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, diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 099832054..346e248e8 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -112,6 +112,9 @@ + {% with circuit.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 94eead673..863b0a0a2 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -23,6 +23,14 @@ {% render_field form.commit_rate %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
Termination
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1388a2c5d..99b82b410 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -113,6 +113,9 @@
+ {% with provider.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
Comments diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html index c137ccdff..4fb3889b1 100644 --- a/netbox/templates/circuits/provider_edit.html +++ b/netbox/templates/circuits/provider_edit.html @@ -19,6 +19,14 @@ {% render_field form.admin_contact %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
Comments
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 049555571..ffd08c415 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -152,6 +152,9 @@
+ {% with device.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% if request.user.is_authenticated %}
diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index afdf25442..dbba77b31 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -63,6 +63,14 @@ {% endif %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
Comments
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 16a4731b6..9808e434f 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -140,6 +140,9 @@
+ {% with rack.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
Non-Racked Devices diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 83dfaf581..c2066afcd 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -16,6 +16,14 @@ {% render_field form.u_height %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
Comments
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 2bd0ffce8..539340965 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -119,6 +119,9 @@
+ {% with site.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
Comments diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index f5f73259d..e3911fc1f 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -14,12 +14,14 @@ {% render_field form.shipping_address %}
-
-
Custom Fields
-
- {% render_custom_fields form %} + {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
-
+ {% endif %}
Comments
diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html new file mode 100644 index 000000000..64c9b11f0 --- /dev/null +++ b/netbox/templates/inc/custom_fields_panel.html @@ -0,0 +1,23 @@ +{% if custom_fields %} +
+
+ Custom Fields +
+ + {% for field, value in custom_fields.items %} + + + + + {% endfor %} +
{{ field }} + {% if value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + N/A + {% endif %} +
+
+{% endif %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 9a0a8db1f..3c1eef905 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -88,6 +88,11 @@
+
+ {% with aggregate.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index de5ed637b..00756b5ac 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -129,6 +129,9 @@
+ {% with ipaddress.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
{% with heading='Parent Prefixes' %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 97991c095..eb36ff977 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -51,6 +51,14 @@ {% render_field form.nat_inside %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} {% endblock %} {% block javascript %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 802e9b90f..d49201bd1 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -109,6 +109,9 @@
+ {% with prefix.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
{% if duplicate_prefix_table.rows %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index d27184824..27f713ba0 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -118,6 +118,9 @@
+ {% with vlan.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index bd3cadc9e..3c89694e2 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -90,6 +90,9 @@
+ {% with vrf.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 0ca380639..9cce543ec 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -73,6 +73,9 @@
+ {% with tenant.custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %}
Comments diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index 3616e5966..b2c472a1c 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -12,6 +12,14 @@ {% render_field form.description %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %}
Comments
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index eaf95ab2c..1bf8731d9 100644 --- a/netbox/tenancy/forms.py +++ b/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() diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 6eb903f8b..4c914dc70 100644 --- a/netbox/tenancy/models.py +++ b/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. diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1ea23dfd2..2fc36948a 100644 --- a/netbox/utilities/views.py +++ b/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