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:
@@ -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
|
||||
#
|
||||
|
@@ -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.
|
||||
|
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.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)
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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'),
|
||||
|
@@ -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
|
||||
#
|
||||
|
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
|
||||
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
|
||||
#
|
||||
|
@@ -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=(
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user