Browse Source

Added URL custom field type; added is_filterable toggle; fixed bulk editing

Jeremy Stretch 8 years ago
parent
commit
d74d85a042

+ 1 - 1
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):

+ 1 - 1
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)

+ 36 - 24
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

+ 3 - 2
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)')),

+ 7 - 3
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.")

+ 2 - 1
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)

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

@@ -12,6 +12,8 @@
                             <i class="glyphicon glyphicon-ok text-success" title="True"></i>
                         {% elif value == False %}
                             <i class="glyphicon glyphicon-remove text-danger" title="False"></i>
+                        {% elif field.type == 500 and value %}
+                            {{ value|urlizetrunc:75 }}
                         {% elif value %}
                             {{ value }}
                         {% elif field.required %}

+ 22 - 11
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)
-
-                # 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