1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Add UI views for custom fields

This commit is contained in:
jeremystretch
2021-06-22 16:28:06 -04:00
parent e59d88bbe9
commit b017927c69
12 changed files with 384 additions and 73 deletions

View File

@@ -1,11 +1,9 @@
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
from utilities.utils import content_type_name
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
from .models import CustomLink, ExportTemplate, JobResult, Webhook
from .utils import FeatureQuery
@@ -59,63 +57,6 @@ class WebhookAdmin(admin.ModelAdmin):
return ', '.join([ct.name for ct in obj.content_types.all()])
#
# Custom fields
#
class CustomFieldForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
exclude = []
widgets = {
'default': forms.TextInput(),
'validation_regex': forms.Textarea(
attrs={
'cols': 80,
'rows': 3,
}
)
}
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
actions = None
form = CustomFieldForm
list_display = [
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
]
list_filter = [
'type', 'required', 'content_types',
]
fieldsets = (
('Custom Field', {
'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
}),
('Assignment', {
'description': 'A custom field must be assigned to one or more object types.',
'fields': ('content_types',)
}),
('Validation Rules', {
'fields': ('validation_minimum', 'validation_maximum', 'validation_regex'),
'classes': ('monospace',)
}),
('Choices', {
'description': 'A selection field must have two or more choices assigned to it.',
'fields': ('choices',)
})
)
def models(self, obj):
ct_names = [content_type_name(ct) for ct in obj.content_types.all()]
return mark_safe('<br/>'.join(ct_names))
#
# Custom links
#

View File

@@ -8,8 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, CSVMultipleContentTypeField, DateTimePicker,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, StaticSelect2Multiple,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@@ -21,6 +22,88 @@ from .utils import FeatureQuery
# Custom fields
#
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
class CustomFieldCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
class Meta:
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'weight',
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
class Meta:
nullable_fields = []
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['type', 'content_types'],
['weight', 'required'],
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
weight = forms.IntegerField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom field models
#
class CustomFieldsMixin:
"""
Extend a Form to include custom field support.

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-06-23 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0060_customlink_button_class'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='customfield',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -6,11 +6,12 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils.safestring import mark_safe
from extras.choices import *
from extras.utils import FeatureQuery
from netbox.models import BigIDModel
from extras.utils import FeatureQuery, extras_features
from netbox.models import ChangeLoggedModel
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice,
)
@@ -29,7 +30,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
class CustomField(BigIDModel):
@extras_features('webhooks')
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
@@ -114,6 +116,9 @@ class CustomField(BigIDModel):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def get_absolute_url(self):
return reverse('extras:customfield', args=[self.pk])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
)
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
from .models import *
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
@@ -28,6 +28,28 @@ OBJECTCHANGE_REQUEST_ID = """
"""
#
# Custom fields
#
class CustomFieldTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
model = CustomField
fields = (
'pk', 'name', 'label', 'type', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices',
)
default_columns = ('pk', 'name', 'label', 'type', 'required', 'description')
#
# Tags
#
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(

View File

@@ -6,11 +6,51 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, ObjectChangeActionChoices
from extras.models import *
from utilities.testing import ViewTestCases, TestCase
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field3', label='Field 3', type=CustomFieldTypeChoices.TYPE_TEXT),
)
for customfield in custom_fields:
customfield.save()
customfield.content_types.add(site_ct)
cls.form_data = {
'name': 'field_x',
'label': 'Field X',
'type': 'text',
'content_types': [site_ct.pk],
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
'default': None,
'weight': 200,
'required': True,
}
cls.csv_data = (
"name,label,type,content_types,weight,filter_logic",
"field4,Field 4,text,dcim.site,100,exact",
"field5,Field 5,text,dcim.site,100,exact",
"field6,Field 6,text,dcim.site,100,exact",
)
cls.bulk_edit_data = {
'required': True,
'weight': 200,
}
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Tag

View File

@@ -1,12 +1,23 @@
from django.urls import path
from extras import views
from extras.models import ConfigContext, JournalEntry, Tag
from extras.models import ConfigContext, CustomField, JournalEntry, Tag
app_name = 'extras'
urlpatterns = [
# Custom fields
path('custom-fields/', views.CustomFieldListView.as_view(), name='customfield_list'),
path('custom-fields/add/', views.CustomFieldEditView.as_view(), name='customfield_add'),
path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'),
path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'),
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'),
path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
path('custom-fields/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='customfield_changelog', kwargs={'model': CustomField}),
# Tags
path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),

View File

@@ -15,11 +15,54 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
from .models import *
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
#
# Custom fields
#
class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all()
class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all()
model_form = forms.CustomFieldForm
class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all()
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all()
model_form = forms.CustomFieldCSVForm
table = tables.CustomFieldTable
class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
#
# Tags
#

View File

@@ -0,0 +1,120 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:customfield_list' %}">Cusotm Fields</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Custom Field
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Required</th>
<td>
{% if object.required %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Values
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|placeholder }}</td>
</tr>
<tr>
<th scope="row">Filter Logic</th>
<td>{{ object.get_filter_logic_display }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Assigned Models
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Validation Rules
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Minimum Value</th>
<td>{{ object.validation_minimum|placeholder }}</td>
</tr>
<tr>
<th scope="row">Maximum Value</th>
<td>{{ object.validation_maximum|placeholder }}</td>
</tr>
<tr>
<th scope="row">Regular Expression</th>
<td>
{% if object.validation_regex %}
<code>{{ object.validation_regex }}</code>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@@ -6,8 +6,9 @@ from io import StringIO
import django_filters
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Count
from django.db.models import Count, Q
from django.forms import BoundField
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.urls import reverse
@@ -28,6 +29,7 @@ __all__ = (
'CSVContentTypeField',
'CSVDataField',
'CSVModelChoiceField',
'CSVMultipleContentTypeField',
'CSVTypedChoiceField',
'DynamicModelChoiceField',
'DynamicModelMultipleChoiceField',
@@ -281,6 +283,20 @@ class CSVContentTypeField(CSVModelChoiceField):
raise forms.ValidationError(f'Invalid object type')
class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
STATIC_CHOICES = True
# TODO: Improve validation of selected ContentTypes
def prepare_value(self, value):
if type(value) is str:
ct_filter = Q()
for name in value.split(','):
app_label, model = name.split('.')
ct_filter |= Q(app_label=app_label, model=model)
return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
return super().prepare_value(value)
#
# Expansion fields
#

View File

@@ -289,6 +289,13 @@ OTHER_MENU = Menu(
url="extras:journalentry_list", add_url=None, import_url=None),
),
),
MenuGroup(
label="Customization",
items=(
MenuItem(label="Custom Fields", url="extras:customfield_list",
add_url="extras:customfield_add", import_url="extras:customfield_import"),
),
),
MenuGroup(
label="Miscellaneous",
items=(

View File

@@ -109,12 +109,12 @@ class ModelTestCase(TestCase):
# Handle ManyToManyFields
if value and type(field) in (ManyToManyField, TaggableManager):
if field.related_model is ContentType:
if field.related_model is ContentType and api:
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
else:
model_dict[key] = sorted([obj.pk for obj in value])
if api:
elif api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType: