From d74d85a042dfa0280885b6e1a06ba95b3f94a06f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Aug 2016 16:45:26 -0400 Subject: [PATCH] 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 | 31 ++++++---- 8 files changed, 73 insertions(+), 42 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 99afafa28..402cd6093 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 447694ce3..d8ccbf986 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -28,6 +28,6 @@ class CustomFieldFilterSet(django_filters.FilterSet): 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 1f36e174e..bb41d59a2 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 @@ class CustomFieldForm(forms.ModelForm): 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 @@ class CustomFieldBulkEditForm(forms.Form): # 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 @@ class CustomFieldFilterForm(forms.Form): 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 4afab3822..1d33ca281 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 217c75780..6d173b62d 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_TEXT = 100 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 @@ class CustomFieldModel(object): 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 08ebb14b4..791c6a1a2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -7,7 +7,7 @@ from dcim.models import Site from extras.models import ( CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, - CF_TYPE_SELECT, + CF_TYPE_SELECT, CF_TYPE_URL, ) @@ -30,6 +30,7 @@ class CustomFieldTestCase(TestCase): {'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 6998c0e00..fed172aa6 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 674a33d22..f36beb69d 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -15,8 +15,8 @@ from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.views.generic import View -from extras.forms import CustomFieldForm, 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 @@ class BulkEditView(View): 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 @@ class BulkEditView(View): 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) + # 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 + ]) - # 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 - ]) + # Deleting CFVs + else: + existing_cfvs.delete() objs_updated = True