From 550a05487d26fee12740c9c84a6955bb82544d1b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Aug 2016 17:20:01 -0400 Subject: [PATCH 01/52] Initial work on custom fields --- netbox/dcim/forms.py | 3 +- netbox/extras/admin.py | 12 ++- netbox/extras/forms.py | 52 ++++++++++ .../extras/migrations/0002_custom_fields.py | 64 +++++++++++++ netbox/extras/models.py | 96 +++++++++++++++++++ netbox/templates/dcim/site_edit.html | 6 ++ .../utilities/render_custom_fields.html | 7 ++ netbox/utilities/templatetags/form_helpers.py | 10 ++ 8 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 netbox/extras/forms.py create mode 100644 netbox/extras/migrations/0002_custom_fields.py create mode 100644 netbox/templates/utilities/render_custom_fields.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5d579c70973..c9923b6f8ee 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3,6 +3,7 @@ from django import forms from django.db.models import Count, Q +from extras.forms import CustomFieldForm from ipam.models import IPAddress from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant @@ -78,7 +79,7 @@ def bulkedit_rackrole_choices(): # Sites # -class SiteForm(forms.ModelForm, BootstrapMixin): +class SiteForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f7ddbbae29d..f12449aed7b 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,6 +1,16 @@ from django.contrib import admin -from .models import Graph, ExportTemplate, TopologyMap, UserAction +from .models import CustomField, CustomFieldValue, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction + + +class CustomFieldChoiceAdmin(admin.TabularInline): + model = CustomFieldChoice + + +@admin.register(CustomField) +class CustomFieldAdmin(admin.ModelAdmin): + inlines = [CustomFieldChoiceAdmin] + list_display = ['name', 'type', 'required', 'default', 'description'] @admin.register(Graph) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py new file mode 100644 index 00000000000..385133dc1ef --- /dev/null +++ b/netbox/extras/forms.py @@ -0,0 +1,52 @@ +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 + + +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)) + + 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) + + # Boolean + elif cf.type == CF_TYPE_BOOLEAN: + if cf.required: + field = forms.BooleanField(required=False) + else: + field = forms.NullBooleanField(required=False) + + # Date + elif cf.type == CF_TYPE_DATE: + field = forms.DateField(blank=not cf.required) + + # Select + elif cf.type == CF_TYPE_SELECT: + field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) + + # Text + else: + field = forms.CharField(max_length=100, blank=not cf.required) + + 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) diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py new file mode 100644 index 00000000000..62bba81ee5f --- /dev/null +++ b/netbox/extras/migrations/0002_custom_fields.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-08-12 19:52 +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'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)), + ('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')), + ], + options={ + 'ordering': ['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)), + ('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()), + ('val_int', models.BigIntegerField(blank=True, null=True)), + ('val_char', models.CharField(blank=True, max_length=100)), + ('val_date', models.DateField(blank=True, null=True)), + ('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='customfieldchoice', + unique_together=set([('field', 'value')]), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 6a32f573876..a7390dec46c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,5 +1,7 @@ from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse from django.template import Template, Context @@ -8,6 +10,26 @@ 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_SELECT = 500 +CUSTOMFIELD_TYPE_CHOICES = ( + (CF_TYPE_TEXT, 'Text'), + (CF_TYPE_INTEGER, 'Integer'), + (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), + (CF_TYPE_DATE, 'Date'), + (CF_TYPE_SELECT, 'Selection'), +) + GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 GRAPH_TYPE_SITE = 300 @@ -40,6 +62,80 @@ ) +class CustomField(models.Model): + obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', + limit_choices_to={'model__in': CUSTOMFIELD_MODELS}) + 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") + 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") + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.label or self.name + + +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') + val_int = models.BigIntegerField(blank=True, null=True) + val_char = models.CharField(max_length=100, blank=True) + val_date = models.DateField(blank=True, null=True) + + class Meta: + ordering = ['obj_type', 'obj_id'] + + def __unicode__(self): + return self.value + + @property + def value(self): + if self.field.type == CF_TYPE_INTEGER: + return self.val_int + if self.field.type == CF_TYPE_BOOLEAN: + return bool(self.val_int) if self.val_int is not None else None + 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 self.val_char + + @value.setter + def value(self, value): + if self.field.type in [CF_TYPE_INTEGER, CF_TYPE_SELECT]: + 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 + else: + self.val_char = value + + +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) + + class Meta: + ordering = ['field', 'weight', 'value'] + unique_together = ['field', 'value'] + + def __unicode__(self): + return self.value + + def clean(self): + if self.field.type != CF_TYPE_SELECT: + raise ValidationError("Custom field choices can only be assigned to selection fields.") + + class Graph(models.Model): type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) weight = models.PositiveSmallIntegerField(default=1000) diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 405f3fd5261..f5f73259df8 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -14,6 +14,12 @@ {% render_field form.shipping_address %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
Comments
diff --git a/netbox/templates/utilities/render_custom_fields.html b/netbox/templates/utilities/render_custom_fields.html new file mode 100644 index 00000000000..f3e5bffa930 --- /dev/null +++ b/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 %} diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index e6e74fdf3cb..3d7540cc76b 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/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') def render_form(form): """ From 6cdb62b67e6a32c8e78d17a85b9c79b181e39152 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 Aug 2016 15:24:23 -0400 Subject: [PATCH 02/52] 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 a44dd763ef1..bc0c55e6ec6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,6 +2,7 @@ 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 @@ # Providers # -class ProviderForm(forms.ModelForm, BootstrapMixin): +class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() @@ -82,7 +83,7 @@ class Meta: # 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 00367a27abe..54afd5c2928 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -3,11 +3,12 @@ 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 @@ def get_absolute_url(self): 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 c9923b6f8ee..2e9c67e2e85 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -165,7 +165,7 @@ class Meta: # 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 Meta: # 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 b1c6b60b71e..6af4535a104 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 @@ def get_queryset(self): 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 @@ def get_queryset(self): 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 @@ def get_queryset(self): 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 f12449aed7b..7d70e3c08cf 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 385133dc1ef..e3126722be0 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 @@ def __init__(self, *args, **kwargs): # 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 62bba81ee5f..361ca13639c 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 a7390dec46c..ba51ac58bd7 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -7,9 +7,8 @@ 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 @@ ) +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 @@ def value(self): 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 @@ def to_response(self, context_dict, filename): 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 00374ef368d..4e64907f6b3 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,6 +4,7 @@ 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 Meta: # Aggregates # -class AggregateForm(forms.ModelForm, BootstrapMixin): +class AggregateForm(BootstrapMixin, CustomFieldForm): class Meta: model = Aggregate @@ -149,7 +150,7 @@ class Meta: # 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 bd49feef10e..feb2899409e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -7,6 +7,7 @@ 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 @@ } -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 @@ def get_absolute_url(self): 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 @@ def annotate_depth(self, limit=None): 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 @@ def get_status_class(self): 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 @@ def get_absolute_url(self): 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 099832054b7..346e248e815 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -112,6 +112,9 @@

{{ circuit.provider }} - {{ circuit.cid }}

+ {% 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 94eead67367..863b0a0a2fe 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 1388a2c5d96..99b82b41001 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -113,6 +113,9 @@

{{ provider }}

+ {% 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 c137ccdff00..4fb3889b19a 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 04955557155..ffd08c41554 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 afdf254421f..dbba77b31d2 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 16a4731b614..9808e434fd7 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -140,6 +140,9 @@

Rack {{ rack.name }}

+ {% 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 83dfaf581ce..c2066afcd90 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 2bd0ffce855..5393409657f 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -119,6 +119,9 @@

{{ site.name }}

+ {% 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 f5f73259df8..e3911fc1ffc 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 00000000000..64c9b11f03d --- /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 9a0a8db1f68..3c1eef905a2 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -88,6 +88,11 @@

{{ aggregate }}

+
+ {% 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 de5ed637b0a..00756b5ac8f 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -129,6 +129,9 @@

{{ ipaddress }}

+ {% 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 97991c09572..eb36ff977f2 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 802e9b90fa9..d49201bd17d 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 d27184824c3..27f713ba05c 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -118,6 +118,9 @@

VLAN {{ vlan.display_name }}

+ {% 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 bd3cadc9ed3..3c89694e220 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -90,6 +90,9 @@

{{ vrf }}

+ {% 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 0ca38063941..9cce543ecf9 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -73,6 +73,9 @@

{{ tenant }}

+ {% 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 3616e59668f..b2c472a1c57 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 eaf95ab2cda..1bf8731d969 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 Meta: # 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 6eb903f8b00..4c914dc7080 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 @@ def get_absolute_url(self): 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 1ea23dfd28b..2fc36948acf 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -15,6 +15,7 @@ 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 @@ def post(self, request, *args, **kwargs): 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 From c60c4ad0dfea41a6a479869b0403e7b3a4718931 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 Aug 2016 16:58:25 -0400 Subject: [PATCH 03/52] Added templates for IPAM objects; cleaned up admin --- netbox/extras/admin.py | 18 +++++++++++++++- netbox/extras/forms.py | 2 +- netbox/extras/models.py | 1 - netbox/ipam/views.py | 4 ++++ netbox/templates/ipam/aggregate_edit.html | 22 +++++++++++++++++++ netbox/templates/ipam/prefix_edit.html | 26 +++++++++++++++++++++++ netbox/templates/ipam/vlan_edit.html | 26 +++++++++++++++++++++++ netbox/templates/ipam/vrf_edit.html | 23 ++++++++++++++++++++ 8 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 netbox/templates/ipam/aggregate_edit.html create mode 100644 netbox/templates/ipam/prefix_edit.html create mode 100644 netbox/templates/ipam/vlan_edit.html create mode 100644 netbox/templates/ipam/vrf_edit.html diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 7d70e3c08cf..26d9c06e694 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,6 +1,21 @@ +from django import forms from django.contrib import admin -from .models import CustomField, CustomFieldValue, CustomFieldChoice, 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): @@ -11,6 +26,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline): class CustomFieldAdmin(admin.ModelAdmin): inlines = [CustomFieldChoiceAdmin] list_display = ['name', 'models', 'type', 'required', 'default', 'description'] + form = CustomFieldForm def models(self, obj): return ', '.join([ct.name for ct in obj.obj_type.all()]) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index e3126722be0..f3e83ecb1e4 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -43,7 +43,7 @@ def __init__(self, *args, **kwargs): 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.label = cf.label if cf.label else cf.name.capitalize() field.help_text = cf.description self.fields[field_name] = field self.custom_fields.append(field_name) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ba51ac58bd7..ccfa3f6264c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -8,7 +8,6 @@ from django.utils.safestring import mark_safe -# NOTE: Any model added here MUST have a GenericRelation defined for CustomField CUSTOMFIELD_MODELS = ( 'site', 'rack', 'device', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a2dd6c31395..f177fe433bd 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -111,6 +111,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_vrf' model = VRF form_class = forms.VRFForm + template_name = 'ipam/vrf_edit.html' cancel_url = 'ipam:vrf_list' @@ -235,6 +236,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_aggregate' model = Aggregate form_class = forms.AggregateForm + template_name = 'ipam/aggregate_edit.html' cancel_url = 'ipam:aggregate_list' @@ -373,6 +375,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_prefix' model = Prefix form_class = forms.PrefixForm + template_name = 'ipam/prefix_edit.html' fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan'] cancel_url = 'ipam:prefix_list' @@ -601,6 +604,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_vlan' model = VLAN form_class = forms.VLANForm + template_name = 'ipam/vlan_edit.html' cancel_url = 'ipam:vlan_list' diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html new file mode 100644 index 00000000000..be499a50970 --- /dev/null +++ b/netbox/templates/ipam/aggregate_edit.html @@ -0,0 +1,22 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Aggregate
+
+ {% render_field form.prefix %} + {% render_field form.rir %} + {% render_field form.date_added %} + {% render_field form.description %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html new file mode 100644 index 00000000000..b91d589699a --- /dev/null +++ b/netbox/templates/ipam/prefix_edit.html @@ -0,0 +1,26 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Prefix
+
+ {% 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 %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html new file mode 100644 index 00000000000..10c633aa534 --- /dev/null +++ b/netbox/templates/ipam/vlan_edit.html @@ -0,0 +1,26 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
VLAN
+
+ {% 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 %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html new file mode 100644 index 00000000000..fc4b438e61d --- /dev/null +++ b/netbox/templates/ipam/vrf_edit.html @@ -0,0 +1,23 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
VRF
+
+ {% render_field form.name %} + {% render_field form.rd %} + {% render_field form.tenant %} + {% render_field form.enforce_unique %} + {% render_field form.description %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +{% endblock %} From 5afb98ffa747fdd8431da4d7190815d39729d807 Mon Sep 17 00:00:00 2001 From: Joonas Bergius Date: Mon, 15 Aug 2016 21:59:01 -0400 Subject: [PATCH 04/52] Added scripts/docker-build.sh for building docker images in CI --- scripts/docker-build.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scripts/docker-build.sh diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100644 index 00000000000..f8691461b4a --- /dev/null +++ b/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 From 4fc0fd9a9a2175f0e6e8a71566b1c49b9733ae9e Mon Sep 17 00:00:00 2001 From: Joonas Bergius Date: Mon, 15 Aug 2016 22:00:34 -0400 Subject: [PATCH 05/52] Produce a docker image when there's a new tag --- .travis.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.travis.yml b/.travis.yml index 01fb25d8fce..e8822e5e330 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,11 @@ +sudo: required + +services: + - docker + +env: + - DOCKER_TAG=$TRAVIS_TAG + language: python python: - "2.7" @@ -6,3 +14,7 @@ install: - pip install pep8 script: - ./scripts/cibuild.sh +after_success: + - if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then + ./scripts/docker-build.sh; + fi From f019253c8e58e686e36cd12ab414e8ce554f28e5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Aug 2016 09:34:26 -0400 Subject: [PATCH 06/52] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0384382b006..6e2359ebe6f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.5.2' +VERSION = '1.5.3-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 7d879bb0dc4e32ad35d3959d2fb4dee64ca409c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Aug 2016 14:57:04 -0400 Subject: [PATCH 07/52] Added bulk editing capability for custom fields --- netbox/circuits/views.py | 22 --- netbox/dcim/forms.py | 4 +- netbox/dcim/views.py | 50 ------- netbox/extras/forms.py | 134 +++++++++++------- netbox/extras/models.py | 12 +- netbox/ipam/views.py | 63 -------- netbox/secrets/views.py | 9 -- netbox/templates/dcim/site_bulk_edit.html | 2 +- netbox/templates/inc/custom_fields_panel.html | 2 +- netbox/tenancy/views.py | 10 -- netbox/utilities/views.py | 56 ++++++-- 11 files changed, 140 insertions(+), 224 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index d84e235252b..28c4d6844b9 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -66,15 +66,6 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'circuits/provider_bulk_edit.html' default_redirect_url = 'circuits:provider_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' @@ -159,19 +150,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'circuits/circuit_bulk_edit.html' default_redirect_url = 'circuits:circuit_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2e9c67e2e85..390fd31fc86 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3,7 +3,7 @@ from django import forms from django.db.models import Count, Q -from extras.forms import CustomFieldForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm from ipam.models import IPAddress from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant @@ -112,7 +112,7 @@ class SiteImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=SiteFromCSVForm) -class SiteBulkEditForm(forms.Form, BootstrapMixin): +class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 17c0e188677..f3b2f4bf107 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -122,16 +122,6 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/site_bulk_edit.html' default_redirect_url = 'dcim:site_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - # # Rack groups @@ -248,20 +238,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/rack_bulk_edit.html' default_redirect_url = 'dcim:rack_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['group', 'tenant', 'role']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['site', 'type', 'width', 'u_height', 'comments']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' @@ -372,15 +348,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/devicetype_bulk_edit.html' default_redirect_url = 'dcim:devicetype_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['manufacturer', 'u_height']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' @@ -682,23 +649,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/device_bulk_edit.html' default_redirect_url = 'dcim:device_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['tenant', 'platform']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - if form.cleaned_data['status']: - status = form.cleaned_data['status'] - fields_to_update['status'] = True if status == 'True' else False - for field in ['tenant', 'device_type', 'device_role', 'serial']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index f3e83ecb1e4..5c0e937af65 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,78 +4,90 @@ from .models import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CustomField, CustomFieldValue -class CustomFieldForm(forms.ModelForm): - custom_fields = [] - - def __init__(self, *args, **kwargs): +def get_custom_fields_for_model(content_type, bulk_editing=False): + """Retrieve all CustomFields applicable to the given ContentType""" + field_dict = {} + custom_fields = CustomField.objects.filter(obj_type=content_type) + + for cf in custom_fields: + field_name = 'cf_{}'.format(str(cf.name)) + + # Integer + if cf.type == CF_TYPE_INTEGER: + field = forms.IntegerField(required=cf.required, initial=cf.default) + + # Boolean + elif cf.type == CF_TYPE_BOOLEAN: + choices = ( + (None, '---------'), + (True, 'True'), + (False, 'False'), + ) + field = forms.NullBooleanField(required=cf.required, 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: + choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] + if not cf.required: + choices = [(0, 'None')] + choices + if bulk_editing: + choices = [(None, '---------')] + choices + field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) + else: + field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) - super(CustomFieldForm, self).__init__(*args, **kwargs) + # Text + else: + field = forms.CharField(max_length=100, required=cf.required, initial=cf.default) - obj_type = ContentType.objects.get_for_model(self._meta.model) + field.model = cf + field.label = cf.label if cf.label else cf.name.capitalize() + field.help_text = cf.description - # Find all CustomFields for this model - custom_fields = CustomField.objects.filter(obj_type=obj_type) - for cf in custom_fields: + field_dict[field_name] = field - field_name = 'cf_{}'.format(str(cf.name)) + return field_dict - # Integer - if cf.type == CF_TYPE_INTEGER: - field = forms.IntegerField(required=cf.required, initial=cf.default) - # Boolean - elif cf.type == CF_TYPE_BOOLEAN: - if cf.required: - field = forms.BooleanField(required=False, initial=bool(cf.default)) - else: - field = forms.NullBooleanField(required=False, initial=bool(cf.default)) +class CustomFieldForm(forms.ModelForm): + custom_fields = [] - # Date - elif cf.type == CF_TYPE_DATE: - field = forms.DateField(required=cf.required, initial=cf.default) + def __init__(self, *args, **kwargs): - # Select - elif cf.type == CF_TYPE_SELECT: - field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) + self.obj_type = ContentType.objects.get_for_model(self._meta.model) - # Text - else: - field = forms.CharField(max_length=100, required=cf.required, initial=cf.default) + super(CustomFieldForm, self).__init__(*args, **kwargs) - field.model = cf - field.label = cf.label if cf.label else cf.name.capitalize() - field.help_text = cf.description - self.fields[field_name] = field - self.custom_fields.append(field_name) + # Add all applicable CustomFields to the form + for name, field in get_custom_fields_for_model(self.obj_type).items(): + self.fields[name] = field + self.custom_fields.append(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)\ + 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): - 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() + for field_name in self.custom_fields: + try: + cfv = CustomFieldValue.objects.get(field=self.fields[field_name].model, obj_type=self.obj_type, + obj_id=self.instance.pk) + except CustomFieldValue.DoesNotExist: + 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) @@ -87,3 +99,19 @@ def save(self, commit=True): 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 + for name, field in get_custom_fields_for_model(self.obj_type, bulk_editing=True).items(): + field.required = False + self.fields[name] = field + self.custom_fields.append(name) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ccfa3f6264c..1896421fc18 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -131,14 +131,22 @@ def value(self, value): 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 + self.val_int = int(bool(value)) if value is not None 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 + # Could be ModelChoiceField or TypedChoiceField + self.val_int = value.id if hasattr(value, 'id') else value else: self.val_char = value + def save(self, *args, **kwargs): + if (self.field.type == CF_TYPE_TEXT and self.value == '') or self.value is None: + if self.pk: + 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}, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f177fe433bd..c7c5a46c6e7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -136,19 +136,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/vrf_bulk_edit.html' default_redirect_url = 'ipam:vrf_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' @@ -261,15 +248,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/aggregate_bulk_edit.html' default_redirect_url = 'ipam:aggregate_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['rir', 'date_added', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' @@ -401,20 +379,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/prefix_bulk_edit.html' default_redirect_url = 'ipam:prefix_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['vrf', 'tenant']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['site', 'status', 'role', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' @@ -527,20 +491,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/ipaddress_bulk_edit.html' default_redirect_url = 'ipam:ipaddress_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['vrf', 'tenant']: - if form.cleaned_data[field] == 0: - fields_to_update[field] = None - elif form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - for field in ['description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' @@ -629,19 +579,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'ipam/vlan_bulk_edit.html' default_redirect_url = 'ipam:vlan_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['site', 'group', 'status', 'role', 'description']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 3518716750c..14ac4fa785c 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -205,15 +205,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'secrets/secret_bulk_edit.html' default_redirect_url = 'secrets:secret_list' - def update_objects(self, pk_list, form): - - fields_to_update = {} - for field in ['role', 'name']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] - - return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) - class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' diff --git a/netbox/templates/dcim/site_bulk_edit.html b/netbox/templates/dcim/site_bulk_edit.html index c5b0e4aa079..f8b6ddc9c25 100644 --- a/netbox/templates/dcim/site_bulk_edit.html +++ b/netbox/templates/dcim/site_bulk_edit.html @@ -6,7 +6,7 @@ {% block select_objects_table %} {% for site in selected_objects %} - {{ site.slug }} + {{ site }} {{ site.tenant }} {% endfor %} diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 64c9b11f03d..a019a985d12 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -8,7 +8,7 @@ {{ field }} - {% if value %} + {% if value != None %} {{ value }} {% elif field.required %} Not defined diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index a1191545862..1055fb9b313 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -107,16 +107,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): template_name = 'tenancy/tenant_bulk_edit.html' 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): permission_required = 'tenancy.delete_tenant' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 2fc36948acf..31485fc7468 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse from django.db import transaction, IntegrityError 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.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError @@ -15,8 +15,8 @@ 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 extras.forms import CustomFieldForm, CustomFieldBulkEditForm +from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from .error_handlers import handle_protectederror from .forms import ConfirmationForm @@ -282,9 +282,22 @@ def post(self, request, *args, **kwargs): pk_list = request.POST.getlist('pk') 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(): - 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: + self.update_custom_fields(pk_list, form, custom_fields) + if updated_count: msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) messages.success(self.request, msg) @@ -292,7 +305,10 @@ def post(self, request, *args, **kwargs): return redirect(redirect_url) 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) if not selected_objects: @@ -305,11 +321,29 @@ def post(self, request, *args, **kwargs): '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: + 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) + + for name in fields: + if form.cleaned_data[name] not in [None, u'']: + for pk in pk_list: + try: + cfv = CustomFieldValue.objects.get(field=form.fields[name].model, obj_type=obj_type, obj_id=pk) + except CustomFieldValue.DoesNotExist: + cfv = CustomFieldValue(field=form.fields[name].model, obj_type=obj_type, obj_id=pk) + cfv.value = form.cleaned_data[name] + cfv.save() class BulkDeleteView(View): From af459cd19b6111bf93de46bd1ec64a0a51b5c875 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Aug 2016 17:48:35 -0400 Subject: [PATCH 08/52] Added some simple tests for custom fields --- netbox/extras/tests/__init__.py | 0 netbox/extras/tests/test_customfields.py | 96 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 netbox/extras/tests/__init__.py create mode 100644 netbox/extras/tests/test_customfields.py diff --git a/netbox/extras/tests/__init__.py b/netbox/extras/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py new file mode 100644 index 00000000000..7812c8c9722 --- /dev/null +++ b/netbox/extras/tests/test_customfields.py @@ -0,0 +1,96 @@ +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, +) + + +class RackTestCase(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}, + ) + + 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() From c8b85202d1458ae0d89f87ff2492d004b3914178 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Aug 2016 17:49:52 -0400 Subject: [PATCH 09/52] Fixed test case name --- netbox/extras/tests/test_customfields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7812c8c9722..08ebb14b4a5 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -11,7 +11,7 @@ ) -class RackTestCase(TestCase): +class CustomFieldTestCase(TestCase): def setUp(self): From aa84d04c8b090ac40b8380e12a7881a00658d4ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 12:04:16 -0400 Subject: [PATCH 10/52] Removed redundant PK list from bulk edit template --- netbox/templates/utilities/bulk_edit_form.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/templates/utilities/bulk_edit_form.html b/netbox/templates/utilities/bulk_edit_form.html index 03d308c9c4a..e723da6564c 100644 --- a/netbox/templates/utilities/bulk_edit_form.html +++ b/netbox/templates/utilities/bulk_edit_form.html @@ -8,9 +8,6 @@

{% block title %}{% endblock %}

{% if request.POST.redirect_url %} {% endif %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %}
From 8d99ad3099b057df8a1b3bad6196a47091d53bbf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 12:41:12 -0400 Subject: [PATCH 11/52] Corrected issue with duplicate queries --- netbox/extras/forms.py | 5 ++++- netbox/utilities/views.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 5c0e937af65..fcb23c3f084 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -111,7 +111,10 @@ def __init__(self, model, *args, **kwargs): 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_editing=True).items(): field.required = False self.fields[name] = field - self.custom_fields.append(name) + custom_fields.append(name) + + self.custom_fields = custom_fields diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 31485fc7468..03b24c7ab9d 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -339,7 +339,8 @@ def update_custom_fields(self, pk_list, form, fields): if form.cleaned_data[name] not in [None, u'']: for pk in pk_list: try: - cfv = CustomFieldValue.objects.get(field=form.fields[name].model, obj_type=obj_type, obj_id=pk) + cfv = CustomFieldValue.objects.select_related('field').get(field=form.fields[name].model, + obj_type=obj_type, obj_id=pk) except CustomFieldValue.DoesNotExist: cfv = CustomFieldValue(field=form.fields[name].model, obj_type=obj_type, obj_id=pk) cfv.value = form.cleaned_data[name] From b7a90dd09a4c90fd7c3f507432a68262a7e45480 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 13:40:06 -0400 Subject: [PATCH 12/52] Added icon for boolean fields --- netbox/templates/inc/custom_fields_panel.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index a019a985d12..6998c0e0014 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -8,7 +8,11 @@ {{ field }} - {% if value != None %} + {% if value == True %} + + {% elif value == False %} + + {% elif value %} {{ value }} {% elif field.required %} Not defined From b0a325f17307d87b8dff5268c9d09541c03eff18 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 13:40:22 -0400 Subject: [PATCH 13/52] More performance improvements --- netbox/extras/forms.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index fcb23c3f084..a7d35493969 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -5,7 +5,9 @@ def get_custom_fields_for_model(content_type, bulk_editing=False): - """Retrieve all CustomFields applicable to the given ContentType""" + """ + Retrieve all CustomFields applicable to the given ContentType + """ field_dict = {} custom_fields = CustomField.objects.filter(obj_type=content_type) @@ -63,9 +65,11 @@ def __init__(self, *args, **kwargs): 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 - self.custom_fields.append(name) + custom_fields.append(name) + self.custom_fields = custom_fields # If editing an existing object, initialize values for all custom fields if self.instance.pk: @@ -78,8 +82,9 @@ def _save_custom_fields(self): for field_name in self.custom_fields: try: - cfv = CustomFieldValue.objects.get(field=self.fields[field_name].model, obj_type=self.obj_type, - obj_id=self.instance.pk) + 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: cfv = CustomFieldValue( field=self.fields[field_name].model, @@ -116,5 +121,4 @@ def __init__(self, model, *args, **kwargs): field.required = False self.fields[name] = field custom_fields.append(name) - self.custom_fields = custom_fields From a33e89fed754960e5d25802fcc5989c6c3b78287 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 14:49:42 -0400 Subject: [PATCH 14/52] Converted to a single column for value storage --- netbox/extras/forms.py | 2 +- .../extras/migrations/0002_custom_fields.py | 6 +- netbox/extras/models.py | 60 ++++++++++++------- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a7d35493969..55fef29957f 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -44,7 +44,7 @@ def get_custom_fields_for_model(content_type, bulk_editing=False): # Text else: - field = forms.CharField(max_length=100, required=cf.required, initial=cf.default) + 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.capitalize() diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 361ca13639c..e430676103f 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-15 19:18 +# Generated by Django 1.10 on 2016-08-17 18:42 from __future__ import unicode_literals from django.db import migrations, models @@ -47,9 +47,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('obj_id', models.PositiveIntegerField()), - ('val_int', models.BigIntegerField(blank=True, null=True)), - ('val_char', models.CharField(blank=True, max_length=100)), - ('val_date', models.DateField(blank=True, null=True)), + ('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')), ], diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1896421fc18..06f6ba5af73 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,3 +1,5 @@ +from datetime import date + from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -97,15 +99,45 @@ class Meta: def __unicode__(self): return self.label or self.name.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 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') - val_int = models.BigIntegerField(blank=True, null=True) - val_char = models.CharField(max_length=100, blank=True) - val_date = models.DateField(blank=True, null=True) + serialized_value = models.CharField(max_length=255) class Meta: ordering = ['obj_type', 'obj_id'] @@ -116,29 +148,11 @@ def __unicode__(self): @property def value(self): - if self.field.type == CF_TYPE_INTEGER: - return self.val_int - if self.field.type == CF_TYPE_BOOLEAN: - return bool(self.val_int) if self.val_int is not None else None - 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) if self.val_int else None - return self.val_char + return self.field.deserialize_value(self.serialized_value) @value.setter def value(self, value): - if self.field.type == CF_TYPE_INTEGER: - self.val_int = value - elif self.field.type == CF_TYPE_BOOLEAN: - self.val_int = int(bool(value)) if value is not None else None - elif self.field.type == CF_TYPE_DATE: - self.val_date = value - elif self.field.type == CF_TYPE_SELECT: - # Could be ModelChoiceField or TypedChoiceField - self.val_int = value.id if hasattr(value, 'id') else value - else: - self.val_char = value + self.serialized_value = self.field.serialize_value(value) def save(self, *args, **kwargs): if (self.field.type == CF_TYPE_TEXT and self.value == '') or self.value is None: From 6ed33af0636e48d4bd9e834b6c9ab79233807aa4 Mon Sep 17 00:00:00 2001 From: Robert Drake Date: Sat, 6 Aug 2016 02:35:42 -0400 Subject: [PATCH 15/52] Changes to Dockerfile to make the build faster To download a new version with docker, I've been running git pull docker-compose build --no-cache This is slow, but no-cache is needed so that "git clone" pulls the latest copy. Most of the slowness comes from pulling down apt files each time a rebuild needs to be done. If we move that into a docker image then only the local changes need to be rebuilt. Further refinements could be done. If the python dependencies that are brought in from requirements.txt could be moved to an image then nothing would change between updates as long as dependant versions hadn't changed. This would probably be more trouble than it's worth, unless you're recreating netbox containers 10-20 times a day. --- Dockerfile | 26 ++++++-------------------- requirements.txt | 1 + 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index c2e2c38ab34..aac301f2bd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,10 @@ -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 + +ADD . /opt/netbox +#RUN git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \ +RUN pip install -r requirements.txt ADD docker/docker-entrypoint.sh /docker-entrypoint.sh ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py diff --git a/requirements.txt b/requirements.txt index 40b0b707fea..f1836ea7f71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +gunicorn==17.5 cryptography==1.4 Django==1.10 django-debug-toolbar==1.4 From 9bdb50c33e181e71c649f8d77a668830fd6eb12b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 17 Aug 2016 15:52:27 -0400 Subject: [PATCH 16/52] Optimized bulk editing of custom fields --- netbox/extras/forms.py | 25 +++++++++++++------------ netbox/extras/models.py | 6 +++--- netbox/utilities/views.py | 30 +++++++++++++++++++----------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 55fef29957f..20b52589d05 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -81,18 +81,19 @@ def __init__(self, *args, **kwargs): 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: - 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() + if self.cleaned_data[field_name] not in [None, u'']: + 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: + 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) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 06f6ba5af73..0a038e657df 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -155,9 +155,9 @@ def value(self, value): self.serialized_value = self.field.serialize_value(value) def save(self, *args, **kwargs): - if (self.field.type == CF_TYPE_TEXT and self.value == '') or self.value is None: - if self.pk: - self.delete() + # 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) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 03b24c7ab9d..19643e664c4 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -277,9 +277,9 @@ def post(self, request, *args, **kwargs): redirect_url = reverse(self.default_redirect_url) 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: - pk_list = request.POST.getlist('pk') + pk_list = [int(pk) for pk in request.POST.getlist('pk')] if '_apply' in request.POST: if hasattr(self.form, 'custom_fields'): @@ -334,17 +334,25 @@ def update_objects(self, pk_list, form, fields): def update_custom_fields(self, pk_list, form, fields): obj_type = ContentType.objects.get_for_model(self.cls) - for name in fields: if form.cleaned_data[name] not in [None, u'']: - for pk in pk_list: - try: - cfv = CustomFieldValue.objects.select_related('field').get(field=form.fields[name].model, - obj_type=obj_type, obj_id=pk) - except CustomFieldValue.DoesNotExist: - cfv = CustomFieldValue(field=form.fields[name].model, obj_type=obj_type, obj_id=pk) - cfv.value = form.cleaned_data[name] - cfv.save() + + field = form.fields[name].model + serialized_value = field.serialize_value(form.cleaned_data[name]) + 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)) + + # Update any existing CFVs. + existing_cfvs.update(serialized_value=serialized_value) + + # Create new CFVs as needed. + CustomFieldValue.objects.bulk_create([ + CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value) + for pk in create_list + ]) class BulkDeleteView(View): From de8fd550cba5f8a28db9fd1927732ff1d144fdae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 10:12:43 -0400 Subject: [PATCH 17/52] Fixes #484: Allow bulk deletion of >1K objects --- netbox/netbox/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6e2359ebe6f..289367d37c4 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -164,6 +164,9 @@ 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 MESSAGE_TAGS = { messages.ERROR: 'danger', From 6f44f4245ec302ad672709216895fef3dd9a81d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 11:44:40 -0400 Subject: [PATCH 18/52] Fixed default value for boolean fields --- netbox/extras/forms.py | 9 ++++++++- netbox/extras/migrations/0002_custom_fields.py | 4 ++-- netbox/extras/models.py | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 20b52589d05..d62caf2aac6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -25,7 +25,14 @@ def get_custom_fields_for_model(content_type, bulk_editing=False): (True, 'True'), (False, 'False'), ) - field = forms.NullBooleanField(required=cf.required, widget=forms.Select(choices=choices)) + 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: diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index e430676103f..97d0b71128b 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-17 18:42 +# Generated by Django 1.10 on 2016-08-18 15:43 from __future__ import unicode_literals from django.db import migrations, models @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('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.')), - ('default', models.CharField(blank=True, help_text=b'Default value for the field. N/A for selection fields.', max_length=100)), + ('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)), ('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={ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 0a038e657df..1965e5f7224 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -90,7 +90,8 @@ class CustomField(models.Model): 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.") - default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. N/A for selection " + 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.") class Meta: From 1d6299622b4ed6c4105ce491dc13f90116d2006e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 14:23:28 -0400 Subject: [PATCH 19/52] Documentation and cleanup --- docs/data-model/extras.md | 26 ++++++++++++++++++++++++++ netbox/extras/admin.py | 1 + netbox/extras/forms.py | 2 +- netbox/extras/models.py | 2 +- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index f9fc82e18f1..429e28837bf 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -1,5 +1,31 @@ This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value. +# Custom Fields + +Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. + +Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports five field types: + +* Free-form text (up to 255 characters) +* Integer +* Boolean (true/false) +* Date +* Selection + +Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. + +Marking the field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) + +When creating a selection field, you must create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. + +## Using Custom Fields + +When a single object is edited, the form will include any custom fields which have been defined for its type. These fields are included in the "Custom Fields" panel. Each custom field value must be saved independently from the core object, so it's best to avoid adding too many custom fields per object. + +When editing multiple objects, values are saved in bulk per field. That is, there is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. + # Export Templates NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 26d9c06e694..99afafa28df 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -20,6 +20,7 @@ def __init__(self, *args, **kwargs): class CustomFieldChoiceAdmin(admin.TabularInline): model = CustomFieldChoice + extra = 5 @admin.register(CustomField) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index d62caf2aac6..9473af20d32 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -54,7 +54,7 @@ def get_custom_fields_for_model(content_type, bulk_editing=False): 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.capitalize() + field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() field.help_text = cf.description field_dict[field_name] = field diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1965e5f7224..a3fdc2e6d60 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -98,7 +98,7 @@ class Meta: ordering = ['name'] def __unicode__(self): - return self.label or self.name.capitalize() + return self.label or self.name.replace('_', ' ').capitalize() def serialize_value(self, value): """ From 63ac4e2c429e7cdd20503e095da4679c602fed30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 15:23:28 -0400 Subject: [PATCH 20/52] Fixes #476: Corrected rack import instructions --- netbox/templates/dcim/rack_import.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/templates/dcim/rack_import.html b/netbox/templates/dcim/rack_import.html index e088f080944..c5775cffdb9 100644 --- a/netbox/templates/dcim/rack_import.html +++ b/netbox/templates/dcim/rack_import.html @@ -53,6 +53,11 @@

CSV Format

Name of tenant (optional) Pied Piper + + Role + Functional role (optional) + Compute + Type Rack type (optional) @@ -71,7 +76,7 @@

CSV Format

Example

-
DC-4,Cage 1400,R101,J12.100,Pied Piper,4-post cabinet,19,42
+
DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42
{% endblock %} From 041a166217f6720dcbfe1fc17d2d17d3918c5289 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 15:31:31 -0400 Subject: [PATCH 21/52] Revert "Changes to Dockerfile to make the build faster" --- Dockerfile | 26 ++++++++++++++++++++------ requirements.txt | 1 - 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index aac301f2bd9..c2e2c38ab34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,24 @@ -FROM python:2.7-wheezy +FROM ubuntu:14.04 -WORKDIR /opt/netbox - -ADD . /opt/netbox -#RUN git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \ -RUN pip install -r requirements.txt +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 ADD docker/docker-entrypoint.sh /docker-entrypoint.sh ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py diff --git a/requirements.txt b/requirements.txt index f1836ea7f71..40b0b707fea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -gunicorn==17.5 cryptography==1.4 Django==1.10 django-debug-toolbar==1.4 From ab90a06c54fb90e5f2af7ef361e2a61ccf57c806 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Aug 2016 16:43:41 -0400 Subject: [PATCH 22/52] Fixes #486: Prompt for secret key only if updating a secret's value --- netbox/project-static/js/secrets.js | 11 +++++++---- netbox/secrets/forms.py | 10 ++++++---- netbox/templates/secrets/secret_edit.html | 2 +- netbox/templates/secrets/secret_import.html | 2 +- netbox/utilities/forms.py | 2 +- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index 7f67cdcda55..fadcc0d39a8 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -25,17 +25,20 @@ $(document).ready(function() { }); // 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'); 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'); return false; } }); - // Prompt the user to enter a private RSA key for decryption + // Saving a private RSA key locally $('#submit_privkey').click(function() { var private_key = $('#user_privkey').val(); sessionStorage.setItem('private_key', private_key); diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 06c963957cf..1e45fe1630d 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -47,8 +47,9 @@ class Meta: # 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)') class Meta: @@ -56,7 +57,8 @@ class Meta: fields = ['role', 'name', 'plaintext', 'plaintext2'] 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): plaintext = self.cleaned_data['plaintext'] @@ -84,7 +86,7 @@ def save(self, *args, **kwargs): class SecretImportForm(BulkImportForm, BootstrapMixin): 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): diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 9a3df1a4550..c2426391f15 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -5,7 +5,7 @@ {% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %} {% block content %} -
+ {% csrf_token %} {{ form.private_key }}
diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 56e4194e493..9c7f6764049 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -17,7 +17,7 @@

Secret Import

{% endif %} - + {% csrf_token %} {% render_form form %}
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f1c942fe65b..4cbc1028bcd 100644 --- a/netbox/utilities/forms.py +++ b/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'] """ csv_form = None + widget = forms.Textarea def __init__(self, csv_form, *args, **kwargs): self.csv_form = csv_form self.columns = self.csv_form().fields.keys() - self.widget = forms.Textarea super(CSVDataField, self).__init__(*args, **kwargs) self.strip = False if not self.label: From 4b4602b7039ecfa290aaa87893776455d489c0f4 Mon Sep 17 00:00:00 2001 From: Robert Drake Date: Sat, 6 Aug 2016 02:35:42 -0400 Subject: [PATCH 23/52] Changes to Dockerfile to make the build faster To download a new version with docker, I've been running git pull docker-compose build --no-cache This is slow, but no-cache is needed so that "git clone" pulls the latest copy. Most of the slowness comes from pulling down apt files each time a rebuild needs to be done. If we move that into a docker image then only the local changes need to be rebuilt. Further refinements could be done. If the python dependencies that are brought in from requirements.txt could be moved to an image then nothing would change between updates as long as dependant versions hadn't changed. This would probably be more trouble than it's worth, unless you're recreating netbox containers 10-20 times a day. --- Dockerfile | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index c2e2c38ab34..fc05a45075c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,10 @@ -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 + +ADD . /opt/netbox +RUN git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \ +RUN pip install gunicorn==17.5 && pip install -r requirements.txt ADD docker/docker-entrypoint.sh /docker-entrypoint.sh ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py From 8cf2ae7851e587666db09450dc1c87367a95b255 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Aug 2016 10:31:16 -0400 Subject: [PATCH 24/52] Fixes #495: Include tenant in prefix and IP CSV export --- netbox/ipam/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index eebc43fad1d..1639daee684 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -275,6 +275,7 @@ def to_csv(self): return ','.join([ str(self.prefix), self.vrf.rd if self.vrf else '', + self.tenant.name if self.tenant else '', self.site.name if self.site else '', self.get_status_display(), self.role.name if self.role else '', @@ -378,6 +379,7 @@ def to_csv(self): return ','.join([ str(self.address), self.vrf.rd if self.vrf else '', + self.tenant.name if self.tenant else '', self.device.identifier if self.device else '', self.interface.name if self.interface else '', 'True' if is_primary else '', From 7a558d8332e6ef74df84f4f1e7f6f4851811a579 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Aug 2016 10:41:37 -0400 Subject: [PATCH 25/52] Fixes #490: Corrected display of circuit commit rate --- netbox/templates/circuits/circuit.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 099832054b7..489dc42a658 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -95,8 +95,8 @@

{{ circuit.provider }} - {{ circuit.cid }}

Commit Rate - {% if circuit.commit_speed %} - {{ circuit.commit_speed_human }} + {% if circuit.commit_rate %} + {{ circuit.commit_rate_human }} {% else %} N/A {% endif %} From eab18a81c9088e118ce2a68e0a449967ce1935c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Aug 2016 12:19:02 -0400 Subject: [PATCH 26/52] Adjusted display of created and last_updated times for primary objects --- netbox/templates/circuits/circuit.html | 9 +-------- netbox/templates/circuits/provider.html | 9 +-------- netbox/templates/dcim/device.html | 9 +-------- netbox/templates/dcim/rack.html | 9 +-------- netbox/templates/dcim/site.html | 9 +-------- netbox/templates/inc/created_updated.html | 3 +++ netbox/templates/ipam/aggregate.html | 9 +-------- netbox/templates/ipam/ipaddress.html | 9 +-------- netbox/templates/ipam/prefix.html | 10 ++-------- netbox/templates/ipam/vlan.html | 9 +-------- netbox/templates/ipam/vrf.html | 9 +-------- netbox/templates/secrets/secret.html | 9 +-------- netbox/templates/tenancy/tenant.html | 9 +-------- netbox/templates/users/userkey.html | 1 + 14 files changed, 17 insertions(+), 96 deletions(-) create mode 100644 netbox/templates/inc/created_updated.html diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 489dc42a658..8844dc43a91 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -102,16 +102,9 @@

{{ circuit.provider }} - {{ circuit.cid }}

{% endif %} - - Created - {{ circuit.created }} - - - Last Updated - {{ circuit.last_updated }} -
+ {% include 'inc/created_updated.html' with obj=circuit %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1388a2c5d96..e5aa6c0c49a 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -103,14 +103,6 @@

{{ provider }}

{% endif %} - - Created - {{ provider.created }} - - - Last Updated - {{ provider.last_updated }} -
@@ -125,6 +117,7 @@

{{ provider }}

{% endif %}
+ {% include 'inc/created_updated.html' with obj=provider %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 04955557155..6bdd48c29cd 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -79,14 +79,6 @@ {% endif %} - - Created - {{ device.created }} - - - Last Updated - {{ device.last_updated }} -
@@ -306,6 +298,7 @@
None found
{% endif %}
+ {% include 'inc/created_updated.html' with obj=device %}
{% if device_bays or device.device_type.is_parent_device %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 16a4731b614..927ec230584 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -130,14 +130,6 @@

Rack {{ rack.name }}

{{ rack.devices.count }} - - Created - {{ rack.created }} - - - Last Updated - {{ rack.last_updated }} -
@@ -187,6 +179,7 @@

Rack {{ rack.name }}

{% endif %}
+ {% include 'inc/created_updated.html' with obj=rack %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 2bd0ffce855..2509721b940 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -109,14 +109,6 @@

{{ site.name }}

{% endif %} - - Created - {{ site.created }} - - - Last Updated - {{ site.last_updated }} -
@@ -131,6 +123,7 @@

{{ site.name }}

{% endif %}
+ {% include 'inc/created_updated.html' with obj=site %}
diff --git a/netbox/templates/inc/created_updated.html b/netbox/templates/inc/created_updated.html new file mode 100644 index 00000000000..001bb6b85c5 --- /dev/null +++ b/netbox/templates/inc/created_updated.html @@ -0,0 +1,3 @@ +

+ Created {{ obj.created }} · Updated {{ obj.last_updated|timesince }} ago +

diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 9a0a8db1f68..ec408d33bac 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -77,16 +77,9 @@

{{ aggregate }}

{% endif %} - - Created - {{ aggregate.created }} - - - Last Updated - {{ aggregate.last_updated }} -
+ {% include 'inc/created_updated.html' with obj=aggregate %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index de5ed637b0a..56f95479b5a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -119,16 +119,9 @@

{{ ipaddress }}

{% endif %} - - Created - {{ ipaddress.created }} - - - Last Updated - {{ ipaddress.last_updated }} -
+ {% include 'inc/created_updated.html' with obj=ipaddress %}
{% with heading='Parent Prefixes' %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 802e9b90fa9..24a40ed667f 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -99,16 +99,10 @@ IP Addresses {{ ipaddress_count }} - - Created - {{ prefix.created }} - - - Last Updated - {{ prefix.last_updated }} -
+ {% include 'inc/created_updated.html' with obj=prefix %} +
{% if duplicate_prefix_table.rows %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index d27184824c3..5b9d4cd3cf5 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -107,17 +107,10 @@

VLAN {{ vlan.display_name }}

N/A {% endif %} - - - Created - {{ vlan.created }} - - - Last Updated - {{ vlan.last_updated }}
+ {% include 'inc/created_updated.html' with obj=vlan %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index bd3cadc9ed3..899b14f54cf 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -79,17 +79,10 @@

{{ vrf }}

N/A {% endif %} - - - Created - {{ vrf.created }} - - - Last Updated - {{ vrf.last_updated }}
+ {% include 'inc/created_updated.html' with obj=vrf %}
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 53cdb4d7ca5..98007c2b97b 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -56,16 +56,9 @@

{{ secret }}

{% endif %} - - Created - {{ secret.created }} - - - Last Updated - {{ secret.last_updated }} -
+ {% include 'inc/created_updated.html' with obj=secret %}
{% if secret|decryptable_by:request.user %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 0ca38063941..c2334bda76a 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -63,14 +63,6 @@

{{ tenant }}

{% endif %} - - Created - {{ tenant.created }} - - - Last Updated - {{ tenant.last_updated }} -
@@ -85,6 +77,7 @@

{{ tenant }}

{% endif %}
+ {% include 'inc/created_updated.html' with obj=tenant %}
diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index cbc748d17b4..6318377c6a1 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -31,6 +31,7 @@

Edit user key

+ {% include 'inc/created_updated.html' with obj=userkey %} {% else %}

You don't have a user key on file.

From 8f34b6b0b963a502f8e5a00554b98fb20dda1adc Mon Sep 17 00:00:00 2001 From: Robert Drake Date: Sun, 21 Aug 2016 23:48:27 -0400 Subject: [PATCH 27/52] fix for Dockerfile It was hard to test with the old syntax. It was cloning the "master" branch, so trying to test a development change was difficult. I believe I've fixed it so that the "master" branch and "develop" branch can use the same Dockerfile options. You override which branch it pulls by setting a build-args variable, either via docker-compose or in the docker build options. --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index fc05a45075c..2cf8b929464 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,10 @@ FROM python:2.7-wheezy WORKDIR /opt/netbox -ADD . /opt/netbox -RUN git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \ -RUN pip install gunicorn==17.5 && pip install -r requirements.txt +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 netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py From b14afaa68735db452c2280066850bb2e032a0a1f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Aug 2016 13:11:57 -0400 Subject: [PATCH 28/52] Updated bulk edit forms to support custom fields --- netbox/circuits/forms.py | 6 +++--- netbox/dcim/forms.py | 4 ++-- netbox/ipam/forms.py | 14 ++++++-------- netbox/tenancy/forms.py | 4 ++-- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bc0c55e6ec6..488648a3b11 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,7 +2,7 @@ from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL -from extras.forms import CustomFieldForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( @@ -47,7 +47,7 @@ class ProviderImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=ProviderFromCSVForm) -class ProviderBulkEditForm(forms.Form, BootstrapMixin): +class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) asn = forms.IntegerField(required=False, label='ASN') account = forms.CharField(max_length=30, required=False, label='Account number') @@ -178,7 +178,7 @@ class CircuitImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=CircuitFromCSVForm) -class CircuitBulkEditForm(forms.Form, BootstrapMixin): +class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 390fd31fc86..9c4f11d315c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -241,7 +241,7 @@ class RackImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=RackFromCSVForm) -class RackBulkEditForm(forms.Form, BootstrapMixin): +class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') @@ -614,7 +614,7 @@ class ChildDeviceImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) -class DeviceBulkEditForm(forms.Form, BootstrapMixin): +class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4e64907f6b3..e6a349bbc56 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,10 +1,8 @@ -from netaddr import IPNetwork - from django import forms from django.db.models import Count from dcim.models import Site, Device, Interface -from extras.forms import CustomFieldForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField @@ -60,7 +58,7 @@ class VRFImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=VRFFromCSVForm) -class VRFBulkEditForm(forms.Form, BootstrapMixin): +class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') description = forms.CharField(max_length=100, required=False) @@ -117,7 +115,7 @@ class AggregateImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=AggregateFromCSVForm) -class AggregateBulkEditForm(forms.Form, BootstrapMixin): +class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') date_added = forms.DateField(required=False) @@ -252,7 +250,7 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=PrefixFromCSVForm) -class PrefixBulkEditForm(forms.Form, BootstrapMixin): +class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') @@ -426,7 +424,7 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=IPAddressFromCSVForm) -class IPAddressBulkEditForm(forms.Form, BootstrapMixin): +class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') @@ -540,7 +538,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=VLANFromCSVForm) -class VLANBulkEditForm(forms.Form, BootstrapMixin): +class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 1bf8731d969..608c99dd25d 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import Count -from extras.forms import CustomFieldForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField from .models import Tenant, TenantGroup @@ -69,7 +69,7 @@ class TenantImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=TenantFromCSVForm) -class TenantBulkEditForm(forms.Form, BootstrapMixin): +class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') From 76f04632904b2da962c86afbba164c444aa592c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Aug 2016 13:20:30 -0400 Subject: [PATCH 29/52] Extended API to include custom fields --- netbox/circuits/api/serializers.py | 10 ++++--- netbox/circuits/api/views.py | 19 +++++++------ netbox/circuits/models.py | 5 +++- netbox/dcim/api/serializers.py | 13 ++++----- netbox/dcim/api/views.py | 30 +++++++++++---------- netbox/dcim/models.py | 4 +++ netbox/extras/api/serializers.py | 25 ++++++++++++++++- netbox/extras/api/views.py | 14 ++++++++-- netbox/ipam/api/serializers.py | 24 ++++++++++------- netbox/ipam/api/views.py | 43 ++++++++++++++++-------------- netbox/ipam/models.py | 8 +++++- netbox/tenancy/api/serializers.py | 5 ++-- netbox/tenancy/api/views.py | 9 ++++--- netbox/tenancy/models.py | 4 ++- 14 files changed, 139 insertions(+), 74 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ed60339634d..45f9e1b1025 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,6 +2,7 @@ from circuits.models import Provider, CircuitType, Circuit from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from extras.api.serializers import CustomFieldsSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -9,11 +10,12 @@ # Providers # -class ProviderSerializer(serializers.ModelSerializer): +class ProviderSerializer(CustomFieldsSerializer, serializers.ModelSerializer): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'custom_fields'] class ProviderNestedSerializer(ProviderSerializer): @@ -43,7 +45,7 @@ class Meta(CircuitTypeSerializer.Meta): # Circuits # -class CircuitSerializer(serializers.ModelSerializer): +class CircuitSerializer(CustomFieldsSerializer, serializers.ModelSerializer): provider = ProviderNestedSerializer() type = CircuitTypeNestedSerializer() tenant = TenantNestedSerializer() @@ -53,7 +55,7 @@ class CircuitSerializer(serializers.ModelSerializer): class Meta: model = Circuit fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', - 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments'] + 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields'] class CircuitNestedSerializer(CircuitSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 74cc6656d47..35b13e49276 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,22 +3,23 @@ from circuits.models import Provider, CircuitType, Circuit from circuits.filters import CircuitFilter +from extras.api.views import CustomFieldModelAPIView from . import serializers -class ProviderListView(generics.ListAPIView): +class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all providers """ - queryset = Provider.objects.all() + queryset = Provider.objects.prefetch_related('custom_field_values') serializer_class = serializers.ProviderSerializer -class ProviderDetailView(generics.RetrieveAPIView): +class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single provider """ - queryset = Provider.objects.all() + queryset = Provider.objects.prefetch_related('custom_field_values') serializer_class = serializers.ProviderSerializer @@ -38,18 +39,20 @@ class CircuitTypeDetailView(generics.RetrieveAPIView): serializer_class = serializers.CircuitTypeSerializer -class CircuitListView(generics.ListAPIView): +class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): """ List circuits (filterable) """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device') + queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + .prefetch_related('custom_field_values') serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter -class CircuitDetailView(generics.RetrieveAPIView): +class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single circuit """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device') + queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + .prefetch_related('custom_field_values') serializer_class = serializers.CircuitSerializer diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 54afd5c2928..fd4fdf63450 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,9 +1,10 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.urlresolvers import reverse from django.db import models from dcim.fields import ASNField from dcim.models import Site, Interface -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel @@ -21,6 +22,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): noc_contact = models.TextField(blank=True, verbose_name='NOC contact') admin_contact = models.TextField(blank=True, verbose_name='Admin contact') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['name'] @@ -79,6 +81,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['provider', 'cid'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 706bf4a5223..786c8f717cc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,6 +6,7 @@ DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, ) +from extras.api.serializers import CustomFieldsSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -13,13 +14,13 @@ # Sites # -class SiteSerializer(serializers.ModelSerializer): +class SiteSerializer(CustomFieldsSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: model = Site fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', - 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] class SiteNestedSerializer(SiteSerializer): @@ -68,7 +69,7 @@ class Meta(RackRoleSerializer.Meta): # -class RackSerializer(serializers.ModelSerializer): +class RackSerializer(CustomFieldsSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = RackGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -77,7 +78,7 @@ class RackSerializer(serializers.ModelSerializer): class Meta: model = Rack fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments'] + 'u_height', 'comments', 'custom_fields'] class RackNestedSerializer(RackSerializer): @@ -237,7 +238,7 @@ class Meta: fields = ['id', 'family', 'address'] -class DeviceSerializer(serializers.ModelSerializer): +class DeviceSerializer(CustomFieldsSerializer, serializers.ModelSerializer): device_type = DeviceTypeNestedSerializer() device_role = DeviceRoleNestedSerializer() tenant = TenantNestedSerializer() @@ -252,7 +253,7 @@ class Meta: model = Device fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'comments'] + 'primary_ip6', 'comments', 'custom_fields'] def get_parent_device(self, obj): try: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5bde03a6e22..193a16ad320 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -13,29 +13,30 @@ InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters -from .exceptions import MissingFilterException -from . import serializers +from extras.api.views import CustomFieldModelAPIView from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from utilities.api import ServiceUnavailable +from .exceptions import MissingFilterException +from . import serializers # # Sites # -class SiteListView(generics.ListAPIView): +class SiteListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all sites """ - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values') serializer_class = serializers.SiteSerializer -class SiteDetailView(generics.RetrieveAPIView): +class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single site """ - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values') serializer_class = serializers.SiteSerializer @@ -84,20 +85,20 @@ class RackRoleDetailView(generics.RetrieveAPIView): # Racks # -class RackListView(generics.ListAPIView): +class RackListView(CustomFieldModelAPIView, generics.ListAPIView): """ List racks (filterable) """ - queryset = Rack.objects.select_related('site', 'group', 'tenant') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('custom_field_values') serializer_class = serializers.RackSerializer filter_class = filters.RackFilter -class RackDetailView(generics.RetrieveAPIView): +class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single rack """ - queryset = Rack.objects.select_related('site', 'group', 'tenant') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('custom_field_values') serializer_class = serializers.RackDetailSerializer @@ -209,24 +210,25 @@ class PlatformDetailView(generics.RetrieveAPIView): # Devices # -class DeviceListView(generics.ListAPIView): +class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): """ List devices (filterable) """ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside', - 'primary_ip6__nat_outside') + 'primary_ip6__nat_outside', + 'custom_field_values') serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] -class DeviceDetailView(generics.RetrieveAPIView): +class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single device """ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', - 'rack__site', 'parent_bay') + 'rack__site', 'parent_bay').prefetch_related('custom_field_values') serializer_class = serializers.DeviceSerializer diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a6bc733c5a6..b8185881f7b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import MultipleObjectsReturned, ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -228,6 +229,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): physical_address = models.CharField(max_length=200, blank=True) shipping_address = models.CharField(max_length=200, blank=True) comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = SiteManager() @@ -339,6 +341,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)]) comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = RackManager() @@ -752,6 +755,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IPv6') comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = DeviceManager() diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e0f24cfe720..a7dbeb5ed9a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,6 +1,29 @@ from rest_framework import serializers -from extras.models import Graph +from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph + + +class CustomFieldsSerializer(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): + fields = {cf.name: None for cf in self.context['view'].custom_fields} + for cfv in obj.custom_field_values.all(): + if cfv.field.type == CF_TYPE_SELECT: + fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data + else: + fields[cfv.field.name] = cfv.value + return fields + + +class CustomFieldChoiceSerializer(serializers.ModelSerializer): + + class Meta: + model = CustomFieldChoice + fields = ['id', 'value'] class GraphSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 81c4e917049..ee83ddf3db0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,9 +1,8 @@ import graphviz from rest_framework import generics from rest_framework.views import APIView -import tempfile -from wsgiref.util import FileWrapper +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 @@ -15,6 +14,17 @@ 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.all() + + class GraphListView(generics.ListAPIView): """ Returns a list of relevant graphs diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a24e1454c6c..8d56d55169f 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from extras.api.serializers import CustomFieldsSerializer from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from tenancy.api.serializers import TenantNestedSerializer @@ -9,12 +10,12 @@ # VRFs # -class VRFSerializer(serializers.ModelSerializer): +class VRFSerializer(CustomFieldsSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] class VRFNestedSerializer(VRFSerializer): @@ -70,12 +71,12 @@ class Meta(RIRSerializer.Meta): # Aggregates # -class AggregateSerializer(serializers.ModelSerializer): +class AggregateSerializer(CustomFieldsSerializer, serializers.ModelSerializer): rir = RIRNestedSerializer() class Meta: model = Aggregate - fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] class AggregateNestedSerializer(AggregateSerializer): @@ -106,7 +107,7 @@ class Meta(VLANGroupSerializer.Meta): # VLANs # -class VLANSerializer(serializers.ModelSerializer): +class VLANSerializer(CustomFieldsSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = VLANGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -114,7 +115,8 @@ class VLANSerializer(serializers.ModelSerializer): class Meta: model = VLAN - fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name'] + fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', + 'custom_fields'] class VLANNestedSerializer(VLANSerializer): @@ -127,7 +129,7 @@ class Meta(VLANSerializer.Meta): # Prefixes # -class PrefixSerializer(serializers.ModelSerializer): +class PrefixSerializer(CustomFieldsSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() @@ -136,7 +138,8 @@ class PrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description'] + fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description', + 'custom_fields'] class PrefixNestedSerializer(PrefixSerializer): @@ -149,14 +152,15 @@ class Meta(PrefixSerializer.Meta): # IP addresses # -class IPAddressSerializer(serializers.ModelSerializer): +class IPAddressSerializer(CustomFieldsSerializer, serializers.ModelSerializer): vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() interface = InterfaceNestedSerializer() class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside'] + fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside', + 'custom_fields'] class IPAddressNestedSerializer(IPAddressSerializer): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 78d2b7f8e59..5400331dad3 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,6 +3,7 @@ from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from ipam import filters +from extras.api.views import CustomFieldModelAPIView from . import serializers @@ -10,20 +11,20 @@ # VRFs # -class VRFListView(generics.ListAPIView): +class VRFListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all VRFs """ - queryset = VRF.objects.select_related('tenant') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter -class VRFDetailView(generics.RetrieveAPIView): +class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VRF """ - queryset = VRF.objects.select_related('tenant') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values') serializer_class = serializers.VRFSerializer @@ -71,20 +72,20 @@ class RIRDetailView(generics.RetrieveAPIView): # Aggregates # -class AggregateListView(generics.ListAPIView): +class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView): """ List aggregates (filterable) """ - queryset = Aggregate.objects.select_related('rir') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter -class AggregateDetailView(generics.RetrieveAPIView): +class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single aggregate """ - queryset = Aggregate.objects.select_related('rir') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values') serializer_class = serializers.AggregateSerializer @@ -92,20 +93,22 @@ class AggregateDetailView(generics.RetrieveAPIView): # Prefixes # -class PrefixListView(generics.ListAPIView): +class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView): """ List prefixes (filterable) """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ + .prefetch_related('custom_field_values') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter -class PrefixDetailView(generics.RetrieveAPIView): +class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single prefix """ - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ + .prefetch_related('custom_field_values') serializer_class = serializers.PrefixSerializer @@ -113,22 +116,22 @@ class PrefixDetailView(generics.RetrieveAPIView): # IP addresses # -class IPAddressListView(generics.ListAPIView): +class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView): """ List IP addresses (filterable) """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside') + .prefetch_related('nat_outside', 'custom_field_values') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter -class IPAddressDetailView(generics.RetrieveAPIView): +class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single IP address """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside') + .prefetch_related('nat_outside', 'custom_field_values') serializer_class = serializers.IPAddressSerializer @@ -157,18 +160,18 @@ class VLANGroupDetailView(generics.RetrieveAPIView): # VLANs # -class VLANListView(generics.ListAPIView): +class VLANListView(CustomFieldModelAPIView, generics.ListAPIView): """ List VLANs (filterable) """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('custom_field_values') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter -class VLANDetailView(generics.RetrieveAPIView): +class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VLAN """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('custom_field_values') serializer_class = serializers.VLANSerializer diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b9bf0e5fd96..7a64705a300 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,7 @@ from netaddr import IPNetwork, cidr_merge from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,7 +9,7 @@ from django.db.models.expressions import RawSQL from dcim.models import Interface -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel @@ -53,6 +54,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space', help_text="Prevent duplicate prefixes/IP addresses within this VRF") description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['name'] @@ -105,6 +107,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') date_added = models.DateField(blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['family', 'prefix'] @@ -241,6 +244,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1) role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = PrefixQuerySet.as_manager() @@ -332,6 +336,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT IP (inside)') description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = IPAddressManager() @@ -436,6 +441,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) description = models.CharField(max_length=100, blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['site', 'group', 'vid'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 30a4a3ca1a8..3e8d019e78c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from extras.api.serializers import CustomFieldsSerializer from tenancy.models import Tenant, TenantGroup @@ -24,12 +25,12 @@ class Meta(TenantGroupSerializer.Meta): # Tenants # -class TenantSerializer(serializers.ModelSerializer): +class TenantSerializer(CustomFieldsSerializer, serializers.ModelSerializer): group = TenantGroupNestedSerializer() class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'comments'] + fields = ['id', 'name', 'slug', 'group', 'comments', 'custom_fields'] class TenantNestedSerializer(TenantSerializer): diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index ca8ab11d7fa..963af6e446f 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,6 +3,7 @@ from tenancy.models import Tenant, TenantGroup from tenancy.filters import TenantFilter +from extras.api.views import CustomFieldModelAPIView from . import serializers @@ -22,18 +23,18 @@ class TenantGroupDetailView(generics.RetrieveAPIView): serializer_class = serializers.TenantGroupSerializer -class TenantListView(generics.ListAPIView): +class TenantListView(CustomFieldModelAPIView, generics.ListAPIView): """ List tenants (filterable) """ - queryset = Tenant.objects.select_related('group') + queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values') serializer_class = serializers.TenantSerializer filter_class = TenantFilter -class TenantDetailView(generics.RetrieveAPIView): +class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single tenant """ - queryset = Tenant.objects.select_related('group') + queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values') serializer_class = serializers.TenantSerializer diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 4c914dc7080..8c1bf5fa44f 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,7 +1,8 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.urlresolvers import reverse from django.db import models -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel @@ -32,6 +33,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): 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)") comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['group', 'name'] From f0a85b1dd3314c33b556021af9392607cce461f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Aug 2016 15:16:49 -0400 Subject: [PATCH 30/52] Optimized API performance --- netbox/circuits/api/serializers.py | 6 +++--- netbox/circuits/api/views.py | 8 ++++---- netbox/dcim/api/serializers.py | 8 ++++---- netbox/dcim/api/views.py | 14 ++++++++------ netbox/extras/api/serializers.py | 18 +++++++++++++++--- netbox/extras/api/views.py | 11 +++++++++-- netbox/extras/models.py | 3 ++- netbox/ipam/api/serializers.py | 12 ++++++------ netbox/ipam/api/views.py | 22 ++++++++++++---------- netbox/tenancy/api/serializers.py | 4 ++-- netbox/tenancy/api/views.py | 4 ++-- 11 files changed, 67 insertions(+), 43 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 45f9e1b1025..d7f32f95842 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,7 +2,7 @@ from circuits.models import Provider, CircuitType, Circuit from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer -from extras.api.serializers import CustomFieldsSerializer +from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -10,7 +10,7 @@ # Providers # -class ProviderSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Provider @@ -45,7 +45,7 @@ class Meta(CircuitTypeSerializer.Meta): # Circuits # -class CircuitSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): provider = ProviderNestedSerializer() type = CircuitTypeNestedSerializer() tenant = TenantNestedSerializer() diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 35b13e49276..866f9283be8 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -11,7 +11,7 @@ class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all providers """ - queryset = Provider.objects.prefetch_related('custom_field_values') + queryset = Provider.objects.prefetch_related('custom_field_values__field') serializer_class = serializers.ProviderSerializer @@ -19,7 +19,7 @@ class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single provider """ - queryset = Provider.objects.prefetch_related('custom_field_values') + queryset = Provider.objects.prefetch_related('custom_field_values__field') serializer_class = serializers.ProviderSerializer @@ -44,7 +44,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): List circuits (filterable) """ queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ - .prefetch_related('custom_field_values') + .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter @@ -54,5 +54,5 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): Retrieve a single circuit """ queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ - .prefetch_related('custom_field_values') + .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 786c8f717cc..f8329d38b00 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,7 +6,7 @@ DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, ) -from extras.api.serializers import CustomFieldsSerializer +from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -14,7 +14,7 @@ # Sites # -class SiteSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: @@ -69,7 +69,7 @@ class Meta(RackRoleSerializer.Meta): # -class RackSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = RackGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -238,7 +238,7 @@ class Meta: fields = ['id', 'family', 'address'] -class DeviceSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): device_type = DeviceTypeNestedSerializer() device_role = DeviceRoleNestedSerializer() tenant = TenantNestedSerializer() diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 193a16ad320..1438876249d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -28,7 +28,7 @@ class SiteListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all sites """ - queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.SiteSerializer @@ -36,7 +36,7 @@ class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single site """ - queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values') + queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.SiteSerializer @@ -89,7 +89,8 @@ class RackListView(CustomFieldModelAPIView, generics.ListAPIView): """ List racks (filterable) """ - queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('custom_field_values') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.RackSerializer filter_class = filters.RackFilter @@ -98,7 +99,8 @@ class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single rack """ - queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('custom_field_values') + queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.RackDetailSerializer @@ -217,7 +219,7 @@ class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', 'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside', - 'custom_field_values') + 'custom_field_values__field') serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] @@ -228,7 +230,7 @@ class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): Retrieve a single device """ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', - 'rack__site', 'parent_bay').prefetch_related('custom_field_values') + 'rack__site', 'parent_bay').prefetch_related('custom_field_values__field') serializer_class = serializers.DeviceSerializer diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a7dbeb5ed9a..ff1e39967a6 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,21 +1,33 @@ from rest_framework import serializers -from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph +from extras.models import CF_TYPE_SELECT, CustomFieldChoice, CustomFieldValue, Graph -class CustomFieldsSerializer(serializers.Serializer): +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(): + + # Suppress database lookups for CustomFieldChoices. Instead, use the cached choice set from the view + # context. if cfv.field.type == CF_TYPE_SELECT: - fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data + 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 else: fields[cfv.field.name] = cfv.value + return fields diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index ee83ddf3db0..0b0638fd713 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -9,7 +9,7 @@ from circuits.models import Provider from dcim.models import Site, Device, Interface, InterfaceConnection -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE +from extras.models import CustomFieldChoice, Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE from .serializers import GraphSerializer @@ -22,7 +22,14 @@ class CustomFieldModelAPIView(object): 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.all() + 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): diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a3fdc2e6d60..be4876fab75 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -129,7 +129,8 @@ def deserialize_value(self, serialized_value): # 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 CustomFieldChoice.objects.get(pk=int(serialized_value)) + return self.choices.get(pk=int(serialized_value)) return serialized_value diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8d56d55169f..653d9eba512 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer -from extras.api.serializers import CustomFieldsSerializer +from extras.api.serializers import CustomFieldSerializer from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from tenancy.api.serializers import TenantNestedSerializer @@ -10,7 +10,7 @@ # VRFs # -class VRFSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): tenant = TenantNestedSerializer() class Meta: @@ -71,7 +71,7 @@ class Meta(RIRSerializer.Meta): # Aggregates # -class AggregateSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): rir = RIRNestedSerializer() class Meta: @@ -107,7 +107,7 @@ class Meta(VLANGroupSerializer.Meta): # VLANs # -class VLANSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() group = VLANGroupNestedSerializer() tenant = TenantNestedSerializer() @@ -129,7 +129,7 @@ class Meta(VLANSerializer.Meta): # Prefixes # -class PrefixSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() @@ -152,7 +152,7 @@ class Meta(PrefixSerializer.Meta): # IP addresses # -class IPAddressSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): vrf = VRFTenantSerializer() tenant = TenantNestedSerializer() interface = InterfaceNestedSerializer() diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 5400331dad3..21ab9335c6c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -15,7 +15,7 @@ class VRFListView(CustomFieldModelAPIView, generics.ListAPIView): """ List all VRFs """ - queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter @@ -24,7 +24,7 @@ class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VRF """ - queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values') + queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field') serializer_class = serializers.VRFSerializer @@ -76,7 +76,7 @@ class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView): """ List aggregates (filterable) """ - queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') serializer_class = serializers.AggregateSerializer filter_class = filters.AggregateFilter @@ -85,7 +85,7 @@ class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single aggregate """ - queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values') + queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field') serializer_class = serializers.AggregateSerializer @@ -98,7 +98,7 @@ class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView): List prefixes (filterable) """ queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ - .prefetch_related('custom_field_values') + .prefetch_related('custom_field_values__field') serializer_class = serializers.PrefixSerializer filter_class = filters.PrefixFilter @@ -108,7 +108,7 @@ class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): Retrieve a single prefix """ queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\ - .prefetch_related('custom_field_values') + .prefetch_related('custom_field_values__field') serializer_class = serializers.PrefixSerializer @@ -121,7 +121,7 @@ class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView): List IP addresses (filterable) """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside', 'custom_field_values') + .prefetch_related('nat_outside', 'custom_field_values__field') serializer_class = serializers.IPAddressSerializer filter_class = filters.IPAddressFilter @@ -131,7 +131,7 @@ class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): Retrieve a single IP address """ queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\ - .prefetch_related('nat_outside', 'custom_field_values') + .prefetch_related('nat_outside', 'custom_field_values__field') serializer_class = serializers.IPAddressSerializer @@ -164,7 +164,8 @@ class VLANListView(CustomFieldModelAPIView, generics.ListAPIView): """ List VLANs (filterable) """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('custom_field_values') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter @@ -173,5 +174,6 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single VLAN """ - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('custom_field_values') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ + .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 3e8d019e78c..bde6f33452d 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from extras.api.serializers import CustomFieldsSerializer +from extras.api.serializers import CustomFieldSerializer from tenancy.models import Tenant, TenantGroup @@ -25,7 +25,7 @@ class Meta(TenantGroupSerializer.Meta): # Tenants # -class TenantSerializer(CustomFieldsSerializer, serializers.ModelSerializer): +class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer): group = TenantGroupNestedSerializer() class Meta: diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 963af6e446f..ce08eb05880 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -27,7 +27,7 @@ class TenantListView(CustomFieldModelAPIView, generics.ListAPIView): """ List tenants (filterable) """ - queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values') + queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field') serializer_class = serializers.TenantSerializer filter_class = TenantFilter @@ -36,5 +36,5 @@ class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single tenant """ - queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values') + queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field') serializer_class = serializers.TenantSerializer From b9dcf9ca12faf1ed56352bef9d62b003445df6fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Aug 2016 15:52:26 -0400 Subject: [PATCH 31/52] Refreshed migrations --- netbox/extras/migrations/0002_custom_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 97d0b71128b..e2c4030652d 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-18 15:43 +# Generated by Django 1.10 on 2016-08-22 19:51 from __future__ import unicode_literals from django.db import migrations, models From 3b36a35b9a00a7f85e66f20339e8e7b872a38dd0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Aug 2016 17:15:20 -0400 Subject: [PATCH 32/52] Fixed API tests --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/views.py | 10 +++++++++- netbox/dcim/tests/test_apis.py | 9 +++++++-- netbox/extras/api/serializers.py | 9 ++++++--- netbox/extras/api/views.py | 2 +- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f8329d38b00..583f63d8b42 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -93,7 +93,7 @@ class RackDetailSerializer(RackSerializer): class Meta(RackSerializer.Meta): fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments', 'front_units', 'rear_units'] + 'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1438876249d..01a8c6f6174 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -430,6 +431,13 @@ class RelatedConnectionsView(APIView): Retrieve all connections related to a given console/power/interface connection """ + def __init__(self): + super(RelatedConnectionsView, self).__init__() + + # Custom fields + self.content_type = ContentType.objects.get_for_model(Device) + self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + def get(self, request): peer_device = request.GET.get('peer-device') @@ -454,7 +462,7 @@ def get(self, request): # Initialize response skeleton response = { - 'device': serializers.DeviceSerializer(device).data, + 'device': serializers.DeviceSerializer(device, context={'view': self}).data, 'console-ports': [], 'power-ports': [], 'interfaces': [], diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index b8ddf942105..f7f63c555d7 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -21,6 +21,7 @@ class SiteTest(APITestCase): 'physical_address', 'shipping_address', 'comments', + 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', @@ -46,7 +47,8 @@ class SiteTest(APITestCase): 'type', 'width', 'u_height', - 'comments' + 'comments', + 'custom_fields', ] graph_fields = [ @@ -125,7 +127,8 @@ class RackTest(APITestCase): 'type', 'width', 'u_height', - 'comments' + 'comments', + 'custom_fields', ] detail_fields = [ @@ -141,6 +144,7 @@ class RackTest(APITestCase): 'width', 'u_height', 'comments', + 'custom_fields', 'front_units', 'rear_units' ] @@ -337,6 +341,7 @@ class DeviceTest(APITestCase): 'primary_ip4', 'primary_ip6', 'comments', + 'custom_fields', ] nested_fields = ['id', 'name', 'display_name'] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ff1e39967a6..4e82b40277c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from extras.models import CF_TYPE_SELECT, CustomFieldChoice, CustomFieldValue, Graph +from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph class CustomFieldSerializer(serializers.Serializer): @@ -17,14 +17,17 @@ def get_custom_fields(self, obj): # Attach any defined CustomFieldValues to their respective CustomFields for cfv in obj.custom_field_values.all(): - # Suppress database lookups for CustomFieldChoices. Instead, use the cached choice set from the view + # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view # context. - if cfv.field.type == CF_TYPE_SELECT: + 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 diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 0b0638fd713..b5928dae151 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -9,7 +9,7 @@ from circuits.models import Provider from dcim.models import Site, Device, Interface, InterfaceConnection -from extras.models import CustomFieldChoice, Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE +from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE from .serializers import GraphSerializer From 28b9dda55d9c84fe58b00f46c50cff7306c128bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 11:18:00 -0400 Subject: [PATCH 33/52] Implemented dynamic filters for custom fields --- netbox/circuits/filters.py | 5 +++-- netbox/dcim/filters.py | 9 +++++---- netbox/extras/filters.py | 31 +++++++++++++++++++++++++++++++ netbox/ipam/filters.py | 11 ++++++----- netbox/tenancy/filters.py | 3 ++- 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 netbox/extras/filters.py diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index e3faa633226..f719290ede9 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -3,11 +3,12 @@ from django.db.models import Q from dcim.models import Site +from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from .models import Provider, Circuit, CircuitType -class ProviderFilter(django_filters.FilterSet): +class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -36,7 +37,7 @@ def search(self, queryset, value): ) -class CircuitFilter(django_filters.FilterSet): +class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 3033ce564bc..eac47445e00 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -2,14 +2,15 @@ from django.db.models import Q +from extras.filters import CustomFieldFilterSet +from tenancy.models import Tenant from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) -from tenancy.models import Tenant -class SiteFilter(django_filters.FilterSet): +class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -58,7 +59,7 @@ class Meta: fields = ['site_id', 'site'] -class RackFilter(django_filters.FilterSet): +class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -139,7 +140,7 @@ class Meta: 'is_network_device'] -class DeviceFilter(django_filters.FilterSet): +class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py new file mode 100644 index 00000000000..d482aa1269f --- /dev/null +++ b/netbox/extras/filters.py @@ -0,0 +1,31 @@ +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): + 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) + for cf in custom_fields: + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 3a73caf8459..be2d127b514 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -5,12 +5,13 @@ from django.db.models import Q from dcim.models import Site, Device, Interface +from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role -class VRFFilter(django_filters.FilterSet): +class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -44,7 +45,7 @@ class Meta: fields = ['name', 'rd'] -class AggregateFilter(django_filters.FilterSet): +class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -75,7 +76,7 @@ def search(self, queryset, value): return queryset.filter(qs_filter) -class PrefixFilter(django_filters.FilterSet): +class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -186,7 +187,7 @@ def _tenant_id(self, queryset, value): ) -class IPAddressFilter(django_filters.FilterSet): +class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', @@ -300,7 +301,7 @@ class Meta: fields = ['site_id', 'site'] -class VLANFilter(django_filters.FilterSet): +class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 3493c94ea54..01d2d578d64 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -2,10 +2,11 @@ from django.db.models import Q +from extras.filters import CustomFieldFilterSet from .models import Tenant, TenantGroup -class TenantFilter(django_filters.FilterSet): +class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', From 74a5960992f621e099e8ddc8255001d7ff6bc8d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 12:05:28 -0400 Subject: [PATCH 34/52] Added custom field support to filter forms --- netbox/circuits/forms.py | 8 ++-- netbox/dcim/forms.py | 11 ++++-- netbox/extras/filters.py | 2 + netbox/extras/forms.py | 24 ++++++++++-- netbox/ipam/forms.py | 17 ++++++--- netbox/templates/inc/filter_panel.html | 51 ++++++++++++++------------ netbox/tenancy/forms.py | 5 ++- 7 files changed, 76 insertions(+), 42 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 488648a3b11..0cb7a30169e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,7 +2,7 @@ from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( @@ -62,7 +62,8 @@ def provider_site_choices(): return [(s.slug, s.name) for s in site_choices] -class ProviderFilterForm(forms.Form, BootstrapMixin): +class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Provider site = forms.MultipleChoiceField(required=False, choices=provider_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -208,7 +209,8 @@ def circuit_site_choices(): return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] -class CircuitFilterForm(forms.Form, BootstrapMixin): +class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Circuit type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9c4f11d315c..8d2e484309c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3,7 +3,7 @@ from django import forms from django.db.models import Count, Q -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant @@ -122,7 +122,8 @@ def site_tenant_choices(): return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices] -class SiteFilterForm(forms.Form, BootstrapMixin): +class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Site tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -273,7 +274,8 @@ def rack_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] -class RackFilterForm(forms.Form, BootstrapMixin): +class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Rack site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', @@ -655,7 +657,8 @@ def device_platform_choices(): return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices] -class DeviceFilterForm(forms.Form, BootstrapMixin): +class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Device site = forms.MultipleChoiceField(required=False, choices=device_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d482aa1269f..447694ce397 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -11,6 +11,8 @@ class CustomFieldFilter(django_filters.Filter): """ 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, diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 9473af20d32..1f36e174e7b 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,7 +4,7 @@ from .models import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CustomField, CustomFieldValue -def get_custom_fields_for_model(content_type, bulk_editing=False): +def get_custom_fields_for_model(content_type, select_empty=False, select_none=True): """ Retrieve all CustomFields applicable to the given ContentType """ @@ -41,9 +41,9 @@ def get_custom_fields_for_model(content_type, bulk_editing=False): # Select elif cf.type == CF_TYPE_SELECT: choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if not cf.required: + if select_none and not cf.required: choices = [(0, 'None')] + choices - if bulk_editing: + if select_empty: choices = [(None, '---------')] + choices field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) else: @@ -125,8 +125,24 @@ def __init__(self, model, *args, **kwargs): # Add all applicable CustomFields to the form custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type, bulk_editing=True).items(): + for name, field in get_custom_fields_for_model(self.obj_type, select_empty=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, select_empty=True, select_none=False)\ + .items() + for name, field in custom_fields: + field.required = False + self.fields[name] = field diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e6a349bbc56..938f5aeb60c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -2,7 +2,7 @@ from django.db.models import Count from dcim.models import Site, Device, Interface -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField @@ -69,7 +69,8 @@ def vrf_tenant_choices(): return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] -class VRFFilterForm(forms.Form, BootstrapMixin): +class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VRF tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -127,7 +128,8 @@ def aggregate_rir_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] -class AggregateFilterForm(forms.Form, BootstrapMixin): +class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Aggregate rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', widget=forms.SelectMultiple(attrs={'size': 8})) @@ -287,7 +289,8 @@ def prefix_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] -class PrefixFilterForm(forms.Form, BootstrapMixin): +class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Prefix parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Network', })) @@ -440,7 +443,8 @@ def ipaddress_vrf_choices(): return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices] -class IPAddressFilterForm(forms.Form, BootstrapMixin): +class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) @@ -575,7 +579,8 @@ def vlan_role_choices(): return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] -class VLANFilterForm(forms.Form, BootstrapMixin): +class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VLAN site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', diff --git a/netbox/templates/inc/filter_panel.html b/netbox/templates/inc/filter_panel.html index 2c73ba0c038..cde76a21c9b 100644 --- a/netbox/templates/inc/filter_panel.html +++ b/netbox/templates/inc/filter_panel.html @@ -1,27 +1,32 @@ {% load form_helpers %} -

-
- - Filter -
-
- - {% for field in filter_form %} -
- {% if field|widget_type == 'checkboxinput' %} - - {% else %} - {{ field.label_tag }} - {{ field }} - {% endif %} +{% if filter_form %} +
+
+ + Filter +
+
+ + {% for field in filter_form %} +
+ {% if field|widget_type == 'checkboxinput' %} + + {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %} +
+ {% endfor %} +
+ + + Clear +
- {% endfor %} -
- -
- + +
-
+{% endif %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 608c99dd25d..40b5b5b1d41 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import Count -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm +from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField from .models import Tenant, TenantGroup @@ -79,6 +79,7 @@ def tenant_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, widget=forms.SelectMultiple(attrs={'size': 8})) From 4a2e80aeee0d1557091f6420992342af84230ffc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 12:11:27 -0400 Subject: [PATCH 35/52] Added a weight to CustomField for ordering fields within a form --- netbox/extras/migrations/0002_custom_fields.py | 5 +++-- netbox/extras/models.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index e2c4030652d..60d00dcebf4 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-22 19:51 +# Generated by Django 1.10 on 2016-08-23 16:10 from __future__ import unicode_literals from django.db import migrations, models @@ -24,6 +24,7 @@ class Migration(migrations.Migration): ('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.')), ('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={ @@ -35,7 +36,7 @@ class Migration(migrations.Migration): 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)), + ('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={ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index be4876fab75..16c954e333b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -93,6 +93,8 @@ class CustomField(models.Model): 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 = ['name'] @@ -168,7 +170,7 @@ 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) + weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list") class Meta: ordering = ['field', 'weight', 'value'] From 25c46894b4310207a82fa3aca1d6b59986340fed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 12:25:23 -0400 Subject: [PATCH 36/52] Corrected CustomField ordering --- netbox/extras/migrations/0002_custom_fields.py | 4 ++-- netbox/extras/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 60d00dcebf4..4afab382297 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-23 16:10 +# Generated by Django 1.10 on 2016-08-23 16:24 from __future__ import unicode_literals from django.db import migrations, models @@ -28,7 +28,7 @@ class Migration(migrations.Migration): ('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'], + 'ordering': ['weight', 'name'], }, ), migrations.CreateModel( diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 16c954e333b..217c757803a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -97,7 +97,7 @@ class CustomField(models.Model): "form") class Meta: - ordering = ['name'] + ordering = ['weight', 'name'] def __unicode__(self): return self.label or self.name.replace('_', ' ').capitalize() From fcd4c9f7de4b1447a21646bb9da2c2b428d6b052 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 12:47:44 -0400 Subject: [PATCH 37/52] Corrected reporting of bulk edits to custom fields --- netbox/utilities/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 19643e664c4..674a33d22ef 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -296,7 +296,9 @@ def post(self, request, *args, **kwargs): # Update custom fields for objects if custom_fields: - self.update_custom_fields(pk_list, form, 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: msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) @@ -334,6 +336,8 @@ def update_objects(self, pk_list, form, fields): 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'']: @@ -354,6 +358,10 @@ def update_custom_fields(self, pk_list, form, fields): for pk in create_list ]) + objs_updated = True + + return len(pk_list) if objs_updated else 0 + class BulkDeleteView(View): cls = None From d74d85a042dfa0280885b6e1a06ba95b3f94a06f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 16:45:26 -0400 Subject: [PATCH 38/52] Added URL custom field type; added is_filterable toggle; fixed bulk editing --- netbox/extras/admin.py | 2 +- netbox/extras/filters.py | 2 +- netbox/extras/forms.py | 60 +++++++++++-------- .../extras/migrations/0002_custom_fields.py | 5 +- netbox/extras/models.py | 10 +++- netbox/extras/tests/test_customfields.py | 3 +- netbox/templates/inc/custom_fields_panel.html | 2 + netbox/utilities/views.py | 33 ++++++---- 8 files changed, 74 insertions(+), 43 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 99afafa28df..402cd609380 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -26,7 +26,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline): @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): inlines = [CustomFieldChoiceAdmin] - list_display = ['name', 'models', 'type', 'required', 'default', 'description'] + list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description'] form = CustomFieldForm def models(self, obj): diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 447694ce397..d8ccbf9866f 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -28,6 +28,6 @@ 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) + custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) for cf in custom_fields: self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 1f36e174e7b..bb41d59a222 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,15 +1,22 @@ +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, CustomField, CustomFieldValue +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, select_empty=False, select_none=True): +def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): """ Retrieve all CustomFields applicable to the given ContentType """ - field_dict = {} - custom_fields = CustomField.objects.filter(obj_type=content_type) + 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)) @@ -40,15 +47,19 @@ def get_custom_fields_for_model(content_type, select_empty=False, select_none=Tr # Select elif cf.type == CF_TYPE_SELECT: - choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if select_none and not cf.required: - choices = [(0, 'None')] + choices - if select_empty: + 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) @@ -88,19 +99,21 @@ def __init__(self, *args, **kwargs): def _save_custom_fields(self): for field_name in self.custom_fields: - if self.cleaned_data[field_name] not in [None, u'']: - 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: - 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() + 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) @@ -125,7 +138,7 @@ def __init__(self, model, *args, **kwargs): # Add all applicable CustomFields to the form custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type, select_empty=True).items(): + 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) @@ -141,8 +154,7 @@ def __init__(self, *args, **kwargs): super(CustomFieldFilterForm, self).__init__(*args, **kwargs) # Add all applicable CustomFields to the form - custom_fields = get_custom_fields_for_model(self.obj_type, select_empty=True, select_none=False)\ - .items() + custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() for name, field in custom_fields: field.required = False self.fields[name] = field diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 4afab382297..1d33ca28176 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-23 16:24 +# Generated by Django 1.10 on 2016-08-23 20:33 from __future__ import unicode_literals from django.db import migrations, models @@ -18,11 +18,12 @@ class Migration(migrations.Migration): 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'Selection')], default=100)), + ('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)')), diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 217c757803a..6d173b62d2e 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from datetime import date from django.contrib.auth.models import User @@ -21,12 +22,14 @@ CF_TYPE_INTEGER = 200 CF_TYPE_BOOLEAN = 300 CF_TYPE_DATE = 400 -CF_TYPE_SELECT = 500 +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'), ) @@ -74,9 +77,9 @@ def custom_fields(self): 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} + return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) else: - return {field: None for field in fields} + return OrderedDict([(field, None) for field in fields]) class CustomField(models.Model): @@ -90,6 +93,7 @@ class CustomField(models.Model): 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.") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 08ebb14b4a5..791c6a1a2ef 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -7,7 +7,7 @@ from extras.models import ( CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, - CF_TYPE_SELECT, + CF_TYPE_SELECT, CF_TYPE_URL, ) @@ -30,6 +30,7 @@ def test_simple_fields(self): {'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) diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 6998c0e0014..fed172aa6d6 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -12,6 +12,8 @@ {% elif value == False %} + {% elif field.type == 500 and value %} + {{ value|urlizetrunc:75 }} {% elif value %} {{ value }} {% elif field.required %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 674a33d22ef..f36beb69de1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -15,8 +15,8 @@ from django.utils.http import is_safe_url from django.views.generic import View -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm -from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction +from extras.forms import CustomFieldForm +from extras.models import CustomFieldValue, ExportTemplate, UserAction from .error_handlers import handle_protectederror from .forms import ConfirmationForm @@ -327,6 +327,7 @@ 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]: @@ -342,21 +343,31 @@ def update_custom_fields(self, pk_list, form, fields): if form.cleaned_data[name] not in [None, u'']: field = form.fields[name].model - serialized_value = field.serialize_value(form.cleaned_data[name]) + + # 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)) - # Update any existing CFVs. - existing_cfvs.update(serialized_value=serialized_value) - - # Create new CFVs as needed. - CustomFieldValue.objects.bulk_create([ - CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value) - for pk in create_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 From 25f1fcc6cb368754f90b833d59ac5d96becfe8dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Aug 2016 09:42:22 -0400 Subject: [PATCH 39/52] Updated docs to add URL custom field type --- docs/data-model/extras.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 429e28837bf..3a23910282e 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -6,12 +6,13 @@ Each object in NetBox is represented in the database as a discrete table, and ea 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 five field types: +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. From e0b17b1496d8a434854356759fc6f99fbc9a697c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 12 Sep 2016 09:36:23 -0400 Subject: [PATCH 40/52] Fixes #515: Clarify when 'face' field is required --- netbox/templates/dcim/device_import.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index 56e24013463..5220e6c2d65 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -83,7 +83,7 @@

CSV Format

Face - Rack face; front or rear (optional) + Rack face; front or rear (required if position is set) Rear From 6e5a099834ddccc159681d814b1aca77917df8b9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 12 Sep 2016 09:44:24 -0400 Subject: [PATCH 41/52] Fixes #540: Add links for VLAN roles under VLAN nav menu --- netbox/templates/_base.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f0291f25020..0a9cb20d404 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -167,7 +167,7 @@ {% if perms.ipam.add_rir or perms.ipam.add_role %}
  • {% endif %} -
  • Prefix/VLAN Roles
  • +
  • Prefix Roles
  • {% if perms.ipam.add_role %}
  • Add a Role
  • {% endif %} @@ -186,6 +186,11 @@ {% if perms.ipam.add_vlangroup %}
  • Add a VLAN Group
  • {% endif %} +
  • +
  • VLAN Roles
  • + {% if perms.ipam.add_role %} +
  • Add a Role
  • + {% endif %}