mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-19 09:53:34 -06:00
Add UI views for custom fields
This commit is contained in:
parent
e59d88bbe9
commit
b017927c69
@ -1,11 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
|
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
|
||||||
from utilities.utils import content_type_name
|
from .models import CustomLink, ExportTemplate, JobResult, Webhook
|
||||||
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
|
|
||||||
from .utils import FeatureQuery
|
from .utils import FeatureQuery
|
||||||
|
|
||||||
|
|
||||||
@ -59,63 +57,6 @@ class WebhookAdmin(admin.ModelAdmin):
|
|||||||
return ', '.join([ct.name for ct in obj.content_types.all()])
|
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
|
# Custom links
|
||||||
#
|
#
|
||||||
|
@ -8,8 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
|
|||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
|
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
|
||||||
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
|
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, CSVMultipleContentTypeField, DateTimePicker,
|
||||||
JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
|
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, StaticSelect2Multiple,
|
||||||
|
BOOLEAN_WITH_BLANK_CHOICES,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup
|
from virtualization.models import Cluster, ClusterGroup
|
||||||
from .choices import *
|
from .choices import *
|
||||||
@ -21,6 +22,88 @@ from .utils import FeatureQuery
|
|||||||
# Custom fields
|
# 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:
|
class CustomFieldsMixin:
|
||||||
"""
|
"""
|
||||||
Extend a Form to include custom field support.
|
Extend a Form to include custom field support.
|
||||||
|
23
netbox/extras/migrations/0061_extras_change_logging.py
Normal file
23
netbox/extras/migrations/0061_extras_change_logging.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -6,11 +6,12 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.validators import RegexValidator, ValidationError
|
from django.core.validators import RegexValidator, ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery, extras_features
|
||||||
from netbox.models import BigIDModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice,
|
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)
|
return self.get_queryset().filter(content_types=content_type)
|
||||||
|
|
||||||
|
|
||||||
class CustomField(BigIDModel):
|
@extras_features('webhooks')
|
||||||
|
class CustomField(ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='custom_fields',
|
related_name='custom_fields',
|
||||||
@ -114,6 +116,9 @@ class CustomField(BigIDModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.label or self.name.replace('_', ' ').capitalize()
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ from django.conf import settings
|
|||||||
from utilities.tables import (
|
from utilities.tables import (
|
||||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
|
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
|
||||||
)
|
)
|
||||||
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
|
from .models import *
|
||||||
|
|
||||||
CONFIGCONTEXT_ACTIONS = """
|
CONFIGCONTEXT_ACTIONS = """
|
||||||
{% if perms.extras.change_configcontext %}
|
{% 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):
|
class TagTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
|
@ -6,11 +6,51 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import ObjectChangeActionChoices
|
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, ObjectChangeActionChoices
|
||||||
from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag
|
from extras.models import *
|
||||||
from utilities.testing import ViewTestCases, TestCase
|
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):
|
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||||
model = Tag
|
model = Tag
|
||||||
|
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from extras import views
|
from extras import views
|
||||||
from extras.models import ConfigContext, JournalEntry, Tag
|
from extras.models import ConfigContext, CustomField, JournalEntry, Tag
|
||||||
|
|
||||||
|
|
||||||
app_name = 'extras'
|
app_name = 'extras'
|
||||||
urlpatterns = [
|
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
|
# Tags
|
||||||
path('tags/', views.TagListView.as_view(), name='tag_list'),
|
path('tags/', views.TagListView.as_view(), name='tag_list'),
|
||||||
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
|
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
|
||||||
|
@ -15,11 +15,54 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
|
|||||||
from utilities.views import ContentTypePermissionRequiredMixin
|
from utilities.views import ContentTypePermissionRequiredMixin
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import JobResultStatusChoices
|
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 .reports import get_report, get_reports, run_report
|
||||||
from .scripts import get_scripts, run_script
|
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
|
# Tags
|
||||||
#
|
#
|
||||||
|
120
netbox/templates/extras/customfield.html
Normal file
120
netbox/templates/extras/customfield.html
Normal 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 %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -6,8 +6,9 @@ from io import StringIO
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
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 import BoundField
|
||||||
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -28,6 +29,7 @@ __all__ = (
|
|||||||
'CSVContentTypeField',
|
'CSVContentTypeField',
|
||||||
'CSVDataField',
|
'CSVDataField',
|
||||||
'CSVModelChoiceField',
|
'CSVModelChoiceField',
|
||||||
|
'CSVMultipleContentTypeField',
|
||||||
'CSVTypedChoiceField',
|
'CSVTypedChoiceField',
|
||||||
'DynamicModelChoiceField',
|
'DynamicModelChoiceField',
|
||||||
'DynamicModelMultipleChoiceField',
|
'DynamicModelMultipleChoiceField',
|
||||||
@ -281,6 +283,20 @@ class CSVContentTypeField(CSVModelChoiceField):
|
|||||||
raise forms.ValidationError(f'Invalid object type')
|
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
|
# Expansion fields
|
||||||
#
|
#
|
||||||
|
@ -289,6 +289,13 @@ OTHER_MENU = Menu(
|
|||||||
url="extras:journalentry_list", add_url=None, import_url=None),
|
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(
|
MenuGroup(
|
||||||
label="Miscellaneous",
|
label="Miscellaneous",
|
||||||
items=(
|
items=(
|
||||||
|
@ -109,12 +109,12 @@ class ModelTestCase(TestCase):
|
|||||||
# Handle ManyToManyFields
|
# Handle ManyToManyFields
|
||||||
if value and type(field) in (ManyToManyField, TaggableManager):
|
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])
|
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
|
||||||
else:
|
else:
|
||||||
model_dict[key] = sorted([obj.pk for obj in value])
|
model_dict[key] = sorted([obj.pk for obj in value])
|
||||||
|
|
||||||
if api:
|
elif api:
|
||||||
|
|
||||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||||
if type(getattr(instance, key)) is ContentType:
|
if type(getattr(instance, key)) is ContentType:
|
||||||
|
Loading…
Reference in New Issue
Block a user