Browse Source

Initial work on custom fields

Jeremy Stretch 8 years ago
parent
commit
550a05487d

+ 2 - 1
netbox/dcim/forms.py

@@ -3,6 +3,7 @@ import re
 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()
 

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

+ 52 - 0
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)

+ 64 - 0
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')]),
+        ),
+    ]

+ 96 - 0
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 django.utils.safestring import mark_safe
 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 @@ ACTION_CHOICES = (
 )
 
 
+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)

+ 6 - 0
netbox/templates/dcim/site_edit.html

@@ -15,6 +15,12 @@
         </div>
     </div>
     <div class="panel panel-default">
+        <div class="panel-heading"><strong>Custom Fields</strong></div>
+        <div class="panel-body">
+            {% render_custom_fields form %}
+        </div>
+    </div>
+    <div class="panel panel-default">
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-body">
             {% render_field form.comments %}

+ 7 - 0
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 %}

+ 10 - 0
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):
     """