diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5d579c709..c9923b6f8 100644 --- a/netbox/dcim/forms.py +++ b/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() diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f7ddbbae2..f12449aed 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 000000000..385133dc1 --- /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 000000000..62bba81ee --- /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 6a32f5738..a7390dec4 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 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) diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 405f3fd52..f5f73259d 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 000000000..f3e5bffa9 --- /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 e6e74fdf3..3d7540cc7 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): """