mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial work on custom fields
This commit is contained in:
@ -3,6 +3,7 @@ import re
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
|
|
||||||
|
from extras.forms import CustomFieldForm
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress
|
||||||
from tenancy.forms import bulkedit_tenant_choices
|
from tenancy.forms import bulkedit_tenant_choices
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
@ -78,7 +79,7 @@ def bulkedit_rackrole_choices():
|
|||||||
# Sites
|
# Sites
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteForm(forms.ModelForm, BootstrapMixin):
|
class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
from django.contrib import admin
|
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)
|
@admin.register(Graph)
|
||||||
|
52
netbox/extras/forms.py
Normal file
52
netbox/extras/forms.py
Normal file
@ -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
netbox/extras/migrations/0002_custom_fields.py
Normal file
64
netbox/extras/migrations/0002_custom_fields.py
Normal file
@ -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')]),
|
||||||
|
),
|
||||||
|
]
|
@ -1,5 +1,7 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template import Template, Context
|
from django.template import Template, Context
|
||||||
@ -8,6 +10,26 @@ from django.utils.safestring import mark_safe
|
|||||||
from dcim.models import Site
|
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_INTERFACE = 100
|
||||||
GRAPH_TYPE_PROVIDER = 200
|
GRAPH_TYPE_PROVIDER = 200
|
||||||
GRAPH_TYPE_SITE = 300
|
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):
|
class Graph(models.Model):
|
||||||
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
|
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
|
||||||
weight = models.PositiveSmallIntegerField(default=1000)
|
weight = models.PositiveSmallIntegerField(default=1000)
|
||||||
|
@ -14,6 +14,12 @@
|
|||||||
{% render_field form.shipping_address %}
|
{% render_field form.shipping_address %}
|
||||||
</div>
|
</div>
|
||||||
</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 panel-default">
|
||||||
<div class="panel-heading"><strong>Comments</strong></div>
|
<div class="panel-heading"><strong>Comments</strong></div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
7
netbox/templates/utilities/render_custom_fields.html
Normal file
7
netbox/templates/utilities/render_custom_fields.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
{% if field.name in form.custom_fields %}
|
||||||
|
{% render_field field %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
@ -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')
|
@register.inclusion_tag('utilities/render_form.html')
|
||||||
def render_form(form):
|
def render_form(form):
|
||||||
"""
|
"""
|
||||||
|
Reference in New Issue
Block a user