diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md
index 4c8379d48..efd9883bb 100644
--- a/docs/release-notes/version-3.1.md
+++ b/docs/release-notes/version-3.1.md
@@ -1,11 +1,16 @@
## v3.1-beta2 (FUTURE)
+### Breaking Changes
+
+* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name.
+
### Enhancements
* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class
* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces
* [#7769](https://github.com/netbox-community/netbox/issues/7769) - Enable assignment of IP addresses to an existing FHRP group
* [#7775](https://github.com/netbox-community/netbox/issues/7775) - Enable dynamic config for `CHANGELOG_RETENTION`, `CUSTOM_VALIDATORS`, and `GRAPHQL_ENABLED`
+* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions
### Bug Fixes
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
index 57e6c45a4..007215b6e 100644
--- a/netbox/utilities/forms/fields.py
+++ b/netbox/utilities/forms/fields.py
@@ -15,7 +15,7 @@ from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.urls import reverse
from utilities.choices import unpack_grouped_choices
-from utilities.utils import content_type_name
+from utilities.utils import content_type_identifier, content_type_name
from utilities.validators import EnhancedURLValidator
from . import widgets
from .constants import *
@@ -302,7 +302,7 @@ class CSVContentTypeField(CSVModelChoiceField):
STATIC_CHOICES = True
def prepare_value(self, value):
- return f'{value.app_label}.{value.model}'
+ return content_type_identifier(value)
def to_python(self, value):
if not value:
@@ -328,7 +328,7 @@ class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
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 f'{value.app_label}.{value.model}'
+ return content_type_identifier(value)
#
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index b92cde47c..6ad8ce6ca 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -13,7 +13,7 @@ from django_tables2.utils import Accessor
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
-from .utils import content_type_name
+from .utils import content_type_identifier, content_type_name
from .paginator import EnhancedPaginator, get_paginate_count
@@ -289,16 +289,27 @@ class ContentTypeColumn(tables.Column):
def value(self, value):
if value is None:
return None
- return f"{value.app_label}.{value.model}"
+ return content_type_identifier(value)
class ContentTypesColumn(tables.ManyToManyColumn):
"""
Display a list of ContentType instances.
"""
+ def __init__(self, separator=None, *args, **kwargs):
+ # Use a line break as the default separator
+ if separator is None:
+ separator = mark_safe('
')
+ super().__init__(separator=separator, *args, **kwargs)
+
def transform(self, obj):
return content_type_name(obj)
+ def value(self, value):
+ return ','.join([
+ content_type_identifier(ct) for ct in self.filter(value)
+ ])
+
class ColorColumn(tables.Column):
"""
diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py
index dd7ca4236..499a5e2e7 100644
--- a/netbox/utilities/testing/base.py
+++ b/netbox/utilities/testing/base.py
@@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct
+from utilities.utils import content_type_identifier
from .utils import extract_form_failures
__all__ = (
@@ -110,7 +111,7 @@ class ModelTestCase(TestCase):
if value and type(field) in (ManyToManyField, TaggableManager):
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([content_type_identifier(ct) for ct in value])
else:
model_dict[key] = sorted([obj.pk for obj in value])
@@ -119,7 +120,7 @@ class ModelTestCase(TestCase):
# Replace ContentType numeric IDs with .
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
- model_dict[key] = f'{ct.app_label}.{ct.model}'
+ model_dict[key] = content_type_identifier(ct)
# Convert IPNetwork instances to strings
elif type(value) is IPNetwork:
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 9ddb072f3..1a81369fc 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -344,16 +344,23 @@ def array_to_string(array):
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
-def content_type_name(contenttype):
+def content_type_name(ct):
"""
- Return a proper ContentType name.
+ Return a human-friendly ContentType name (e.g. "DCIM > Site").
"""
try:
- meta = contenttype.model_class()._meta
+ meta = ct.model_class()._meta
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
except AttributeError:
# Model no longer exists
- return f'{contenttype.app_label} > {contenttype.model}'
+ return f'{ct.app_label} > {ct.model}'
+
+
+def content_type_identifier(ct):
+ """
+ Return a "raw" ContentType identifier string suitable for bulk import/export (e.g. "dcim.site").
+ """
+ return f'{ct.app_label}.{ct.model}'
#