Repository: nautobot/nautobot

Issue:
Increase text-type Custom Field length to accommodate Azure Resource IDs
### As ...

P.D. - Plugin Developer

### I want ...

to be able to store an Azure Resource IDs whose lengths are over 255 characters in a custom field, in both 1.6 and 2.x.

For example, this is the generic form of a [subnet's ID in Azure's REST API](https://learn.microsoft.com/en-us/rest/api/virtualnetwork/subnets/get):

```
/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName}
```

In the above, `resourceGroupName`, `virtualNetworkName`, and `subnetName` are arbitrary strings created by an Azure admin, thus can have arbitrary length. 

There isn't a shorter, globally- or subscription-unique identifier I can find in Azure's API, such as an object UUID or something like AWS' IDs (e.g. `subnet-xxxxxxxxxxxxxxxx` for subnets in AWS).

Further reading on Azure resource naming: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules

### So that ...

my SSOT plugin may use the resource ID stored in the custom field to reference back to the Azure API object to allow DiffSync to locate the associated records.

### I know this is done when...

my plugin can use our Azure resources whose IDs are larger than 255 characters to lookup objects in Nautobot that originated in Azure, and to use the Azure resource ID on a given object in Nautobot to reference back to the canonical object in Azure's API. Currently, that's at least some Azure Virtual Networks' subnets (mapped to IP Prefixes for us), although I'm still investigating what other Azure resource types have similar concerns.

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [X] Misc (including Data Sources)
- [ ] Organization
- [X] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [ ] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

Based on the [1.6 documentation of Custom Fields](https://docs.nautobot.com/projects/core/en/v1.6.10/user-guides/custom-fields/) and a cursory look at `nautobot/extras/models/customfields.py` on main branch, I believe the 255 character limit is imposed by the existing schema.

### External Dependencies

_No response_


Discussion:
Related to but distinct from #2906 
A workaround in the interim would be to either use a `Markdown` or `JSON` custom field, as neither of those impose an arbitrary length limit.
Are those fields searchable? In other words, can I use them to pull a specific object by the custom field's value?
Yes, they're usable as filters in the UI and REST API, as well as in the ORM  - here it is as a Markdown custom field:

![image](https://github.com/nautobot/nautobot/assets/5603551/666a7602-83e9-40c8-aafc-5fcdc0d5e0e0)

Probably makes sense to make this an attribute of a custom field definition. Make it optional, if not set, no limit, otherwise enforced.
Let's reuse the min and max value fields that already exist on the custom field model to support numeric and text type fields. Leave the default null values for the fields, and only enforce them when a value is specified.

<img width="1120" src="https://api.zenhub.com/attachedFiles/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBMnZOQXc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--86ba9d505d57a293eed8376582aa43078b5f5a5b/Screenshot%202024-03-11%20at%201.18.01%20PM.png" alt="Screenshot 2024-03-11 at 1.18.01 PM.png" />


Resolving patch:
diff --git a/nautobot/extras/choices.py b/nautobot/extras/choices.py
--- a/nautobot/extras/choices.py
+++ b/nautobot/extras/choices.py
@@ -64,11 +64,25 @@ class CustomFieldTypeChoices(ChoiceSet):
         (TYPE_MARKDOWN, "Markdown"),
     )
 
+    # Types that support validation_minimum/validation_maximum
+    MIN_MAX_TYPES = (
+        TYPE_TEXT,
+        TYPE_INTEGER,
+        TYPE_URL,
+        TYPE_SELECT,
+        TYPE_MULTISELECT,
+        TYPE_JSON,
+        TYPE_MARKDOWN,
+    )
+
+    # Types that support validation_regex
     REGEX_TYPES = (
         TYPE_TEXT,
         TYPE_URL,
         TYPE_SELECT,
         TYPE_MULTISELECT,
+        TYPE_JSON,
+        TYPE_MARKDOWN,
     )
 
 
diff --git a/nautobot/extras/models/customfields.py b/nautobot/extras/models/customfields.py
--- a/nautobot/extras/models/customfields.py
+++ b/nautobot/extras/models/customfields.py
@@ -1,5 +1,6 @@
 from collections import OrderedDict
 from datetime import date, datetime
+import json
 import logging
 import re
 
@@ -390,13 +391,13 @@ class CustomField(BaseModel, ChangeLoggedModel, NotesMixin):
         blank=True,
         null=True,
         verbose_name="Minimum value",
-        help_text="Minimum allowed value (for numeric fields).",
+        help_text="Minimum allowed value (for numeric fields) or length (for text fields).",
     )
     validation_maximum = models.BigIntegerField(
         blank=True,
         null=True,
         verbose_name="Maximum value",
-        help_text="Maximum allowed value (for numeric fields).",
+        help_text="Maximum allowed value (for numeric fields) or length (for text fields).",
     )
     validation_regex = models.CharField(
         blank=True,
@@ -464,16 +465,16 @@ def clean(self):
             except ValidationError as err:
                 raise ValidationError({"default": f'Invalid default value "{self.default}": {err.message}'})
 
-        # Minimum/maximum values can be set only for numeric fields
-        if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
-            raise ValidationError({"validation_minimum": "A minimum value may be set only for numeric fields"})
-        if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
-            raise ValidationError({"validation_maximum": "A maximum value may be set only for numeric fields"})
+        # Minimum/maximum values can be set only for fields that support them
+        if self.validation_minimum is not None and self.type not in CustomFieldTypeChoices.MIN_MAX_TYPES:
+            raise ValidationError({"validation_minimum": "A minimum value may not be set for fields of this type"})
+        if self.validation_maximum is not None and self.type not in CustomFieldTypeChoices.MIN_MAX_TYPES:
+            raise ValidationError({"validation_maximum": "A maximum value may not be set for fields of this type"})
 
         # Regex validation can be set only for text, url, select and multi-select fields
         if self.validation_regex and self.type not in CustomFieldTypeChoices.REGEX_TYPES:
             raise ValidationError(
-                {"validation_regex": "Regular expression validation is supported only for text, URL and select fields"}
+                {"validation_regex": "Regular expression validation is not supported for fields of this type"}
             )
 
         # Choices can be set only on selection fields
@@ -540,13 +541,35 @@ def to_form_field(
                 widget=DatePicker(),
             )
 
-        # Text and URL
-        elif self.type in (CustomFieldTypeChoices.TYPE_URL, CustomFieldTypeChoices.TYPE_TEXT):
+        # Text-like fields
+        elif self.type in (
+            CustomFieldTypeChoices.TYPE_URL,
+            CustomFieldTypeChoices.TYPE_TEXT,
+            CustomFieldTypeChoices.TYPE_MARKDOWN,
+        ):
             if self.type == CustomFieldTypeChoices.TYPE_URL:
-                field = LaxURLField(required=required, initial=initial)
+                field = LaxURLField(
+                    required=required,
+                    initial=initial,
+                    min_length=self.validation_minimum,
+                    max_length=self.validation_maximum,
+                )
             elif self.type == CustomFieldTypeChoices.TYPE_TEXT:
-                field = forms.CharField(max_length=255, required=required, initial=initial)
-
+                field = forms.CharField(
+                    required=required,
+                    initial=initial,
+                    min_length=self.validation_minimum,
+                    max_length=self.validation_maximum,
+                )
+            elif self.type == CustomFieldTypeChoices.TYPE_MARKDOWN:
+                field = CommentField(
+                    required=required,
+                    initial=initial,
+                    widget=SmallTextarea,
+                    label=None,
+                    min_length=self.validation_minimum,
+                    max_length=self.validation_maximum,
+                )
             if self.validation_regex:
                 field.validators = [
                     RegexValidator(
@@ -555,12 +578,10 @@ def to_form_field(
                     )
                 ]
 
-        # Markdown
-        elif self.type == CustomFieldTypeChoices.TYPE_MARKDOWN:
-            field = CommentField(widget=SmallTextarea, label=None)
-
         # JSON
         elif self.type == CustomFieldTypeChoices.TYPE_JSON:
+            # Unlike the above cases, we don't apply min_length/max_length to the field,
+            # nor do we add a RegexValidator to the field, as these all apply after parsing and validating the JSON
             if simple_json_filter:
                 field = JSONField(encoder=DjangoJSONEncoder, required=required, initial=None, widget=TextInput)
             else:
@@ -621,15 +642,33 @@ def validate(self, value):
         """
         if value not in [None, "", []]:
             # Validate text field
-            if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL):
+            if self.type in (
+                CustomFieldTypeChoices.TYPE_TEXT,
+                CustomFieldTypeChoices.TYPE_URL,
+                CustomFieldTypeChoices.TYPE_MARKDOWN,
+            ):
                 if not isinstance(value, str):
                     raise ValidationError("Value must be a string")
-
+                if self.validation_minimum is not None and len(value) < self.validation_minimum:
+                    raise ValidationError(f"Value must be at least {self.validation_minimum} characters in length")
+                if self.validation_maximum is not None and len(value) > self.validation_maximum:
+                    raise ValidationError(f"Value must not exceed {self.validation_maximum} characters in length")
                 if self.validation_regex and not re.search(self.validation_regex, value):
                     raise ValidationError(f"Value must match regex '{self.validation_regex}'")
 
+            # Validate JSON
+            elif self.type == CustomFieldTypeChoices.TYPE_JSON:
+                if self.validation_regex or self.validation_minimum is not None or self.validation_maximum is not None:
+                    json_value = json.dumps(value)
+                    if self.validation_minimum is not None and len(json_value) < self.validation_minimum:
+                        raise ValidationError(f"Value must be at least {self.validation_minimum} characters in length")
+                    if self.validation_maximum is not None and len(json_value) > self.validation_maximum:
+                        raise ValidationError(f"Value must not exceed {self.validation_maximum} characters in length")
+                    if self.validation_regex and not re.search(self.validation_regex, json_value):
+                        raise ValidationError(f"Value must match regex '{self.validation_regex}'")
+
             # Validate integer
-            if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+            elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
                 try:
                     value = int(value)
                 except ValueError:
@@ -640,14 +679,14 @@ def validate(self, value):
                     raise ValidationError(f"Value must not exceed {self.validation_maximum}")
 
             # Validate boolean
-            if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+            elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
                 try:
                     value = is_truthy(value)
                 except ValueError as exc:
                     raise ValidationError("Value must be true or false.") from exc
 
             # Validate date
-            if self.type == CustomFieldTypeChoices.TYPE_DATE:
+            elif self.type == CustomFieldTypeChoices.TYPE_DATE:
                 if not isinstance(value, date):
                     try:
                         datetime.strptime(value, "%Y-%m-%d")
@@ -655,13 +694,13 @@ def validate(self, value):
                         raise ValidationError("Date values must be in the format YYYY-MM-DD.")
 
             # Validate selected choice
-            if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+            elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
                 if value not in self.custom_field_choices.values_list("value", flat=True):
                     raise ValidationError(
                         f"Invalid choice ({value}). Available choices are: {', '.join(self.custom_field_choices.values_list('value', flat=True))}"
                     )
 
-            if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
                 if isinstance(value, str):
                     value = value.split(",")
                 if not set(value).issubset(self.custom_field_choices.values_list("value", flat=True)):
@@ -721,6 +760,11 @@ def clean(self):
         if self.custom_field.type not in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
             raise ValidationError("Custom field choices can only be assigned to selection fields.")
 
+        if self.custom_field.validation_minimum is not None and len(self.value) < self.custom_field.validation_minimum:
+            raise ValidationError(f"Value must be at least {self.custom_field.validation_minimum} characters long.")
+        if self.custom_field.validation_maximum is not None and len(self.value) > self.custom_field.validation_maximum:
+            raise ValidationError(f"Value must not exceed {self.custom_field.validation_maximum} characters long.")
+
         if not re.search(self.custom_field.validation_regex, self.value):
             raise ValidationError(f"Value must match regex {self.custom_field.validation_regex} got {self.value}.")
 


Test patch:
diff --git a/nautobot/extras/tests/test_customfields.py b/nautobot/extras/tests/test_customfields.py
--- a/nautobot/extras/tests/test_customfields.py
+++ b/nautobot/extras/tests/test_customfields.py
@@ -1,3 +1,4 @@
+import json
 import logging
 
 from django.conf import settings
@@ -95,6 +96,11 @@ def test_simple_fields(self):
                 "field_value": "http://example.com/",
                 "empty_value": "",
             },
+            {
+                "field_type": CustomFieldTypeChoices.TYPE_MARKDOWN,
+                "field_value": "### Hello world!\n\n- Item 1\n- Item 2\n- Item 3",
+                "empty_value": "",
+            },
             {
                 "field_type": CustomFieldTypeChoices.TYPE_JSON,
                 "field_value": {"dict_key": "key value"},
@@ -453,130 +459,162 @@ class CustomFieldDataAPITest(APITestCase):
     For tests of the api/extras/custom-fields/ REST API endpoint itself, see test_api.py.
     """
 
-    @classmethod
-    def setUpTestData(cls):
+    user_permissions = (
+        "dcim.add_location",
+        "dcim.change_location",
+        "dcim.view_location",
+    )
+
+    def setUp(self):
+        super().setUp()
         content_type = ContentType.objects.get_for_model(Location)
 
         # Text custom field
-        cls.cf_text = CustomField(
+        self.cf_text = CustomField(
             type=CustomFieldTypeChoices.TYPE_TEXT, label="Text Field", key="text_cf", default="FOO"
         )
-        cls.cf_text.save()
-        cls.cf_text.content_types.set([content_type])
+        self.cf_text.validated_save()
+        self.cf_text.content_types.set([content_type])
 
         # Integer custom field
-        cls.cf_integer = CustomField(
+        self.cf_integer = CustomField(
             type=CustomFieldTypeChoices.TYPE_INTEGER, label="Number Field", key="number_cf", default=12
         )
-        cls.cf_integer.save()
-        cls.cf_integer.content_types.set([content_type])
+        self.cf_integer.validated_save()
+        self.cf_integer.content_types.set([content_type])
 
         # Boolean custom field
-        cls.cf_boolean = CustomField(
+        self.cf_boolean = CustomField(
             type=CustomFieldTypeChoices.TYPE_BOOLEAN,
             label="Boolean Field",
             key="boolean_cf",
             default=False,
         )
-        cls.cf_boolean.save()
-        cls.cf_boolean.content_types.set([content_type])
+        self.cf_boolean.validated_save()
+        self.cf_boolean.content_types.set([content_type])
 
         # Date custom field
-        cls.cf_date = CustomField(
+        self.cf_date = CustomField(
             type=CustomFieldTypeChoices.TYPE_DATE,
             label="Date Field",
             key="date_cf",
             default="2020-01-01",
         )
-        cls.cf_date.save()
-        cls.cf_date.content_types.set([content_type])
+        self.cf_date.validated_save()
+        self.cf_date.content_types.set([content_type])
 
         # URL custom field
-        cls.cf_url = CustomField(
+        self.cf_url = CustomField(
             type=CustomFieldTypeChoices.TYPE_URL,
             label="URL Field",
             key="url_cf",
             default="http://example.com/1",
         )
-        cls.cf_url.save()
-        cls.cf_url.content_types.set([content_type])
+        self.cf_url.validated_save()
+        self.cf_url.content_types.set([content_type])
 
         # Select custom field
-        cls.cf_select = CustomField(
+        self.cf_select = CustomField(
             type=CustomFieldTypeChoices.TYPE_SELECT,
             label="Choice Field",
             key="choice_cf",
         )
-        cls.cf_select.save()
-        cls.cf_select.content_types.set([content_type])
-        CustomFieldChoice.objects.create(custom_field=cls.cf_select, value="Foo")
-        CustomFieldChoice.objects.create(custom_field=cls.cf_select, value="Bar")
-        CustomFieldChoice.objects.create(custom_field=cls.cf_select, value="Baz")
-        cls.cf_select.default = "Foo"
-        cls.cf_select.save()
+        self.cf_select.validated_save()
+        self.cf_select.content_types.set([content_type])
+        CustomFieldChoice.objects.create(custom_field=self.cf_select, value="Foo")
+        CustomFieldChoice.objects.create(custom_field=self.cf_select, value="Bar")
+        CustomFieldChoice.objects.create(custom_field=self.cf_select, value="Baz")
+        self.cf_select.default = "Foo"
+        self.cf_select.validated_save()
 
         # Multi-select custom field
-        cls.cf_multi_select = CustomField(
+        self.cf_multi_select = CustomField(
             type=CustomFieldTypeChoices.TYPE_MULTISELECT,
             label="Multiple Choice Field",
             key="multi_choice_cf",
         )
-        cls.cf_multi_select.save()
-        cls.cf_multi_select.content_types.set([content_type])
-        CustomFieldChoice.objects.create(custom_field=cls.cf_multi_select, value="Foo")
-        CustomFieldChoice.objects.create(custom_field=cls.cf_multi_select, value="Bar")
-        CustomFieldChoice.objects.create(custom_field=cls.cf_multi_select, value="Baz")
-        cls.cf_multi_select.default = ["Foo", "Bar"]
-        cls.cf_multi_select.save()
+        self.cf_multi_select.validated_save()
+        self.cf_multi_select.content_types.set([content_type])
+        CustomFieldChoice.objects.create(custom_field=self.cf_multi_select, value="Foo")
+        CustomFieldChoice.objects.create(custom_field=self.cf_multi_select, value="Bar")
+        CustomFieldChoice.objects.create(custom_field=self.cf_multi_select, value="Baz")
+        self.cf_multi_select.default = ["Foo", "Bar"]
+        self.cf_multi_select.validated_save()
+
+        # Markdown custom field
+        self.cf_markdown = CustomField(
+            type=CustomFieldTypeChoices.TYPE_MARKDOWN,
+            label="Markdown Field",
+            key="markdown_cf",
+            default="# One\n\n## Two\n\n### Three",
+        )
+        self.cf_markdown.validated_save()
+        self.cf_markdown.content_types.set([content_type])
+
+        # JSON custom field
+        self.cf_json = CustomField(
+            type=CustomFieldTypeChoices.TYPE_JSON,
+            label="JSON Field",
+            key="json_cf",
+            default={"dict": ["key1", "key2"]},
+        )
+        self.cf_json.validated_save()
+        self.cf_json.content_types.set([content_type])
+
+        self.all_cfs = [
+            self.cf_text,
+            self.cf_integer,
+            self.cf_boolean,
+            self.cf_date,
+            self.cf_url,
+            self.cf_select,
+            self.cf_multi_select,
+            self.cf_markdown,
+            self.cf_json,
+        ]
 
         if "example_plugin" in settings.PLUGINS:
-            cls.cf_plugin_field = CustomField.objects.get(key="example_plugin_auto_custom_field")
+            self.cf_plugin_field = CustomField.objects.get(key="example_plugin_auto_custom_field")
+            self.all_cfs.append(self.cf_plugin_field)
 
-        cls.statuses = Status.objects.get_for_model(Location)
+        self.statuses = Status.objects.get_for_model(Location)
 
         # Create some locations
-        cls.lt = LocationType.objects.get(name="Campus")
-        cls.locations = (
-            Location.objects.create(name="Location 1", status=cls.statuses[0], location_type=cls.lt),
-            Location.objects.create(name="Location 2", status=cls.statuses[0], location_type=cls.lt),
+        self.lt = LocationType.objects.get(name="Campus")
+        self.locations = (
+            Location.objects.create(name="Location 1", status=self.statuses[0], location_type=self.lt),
+            Location.objects.create(name="Location 2", status=self.statuses[0], location_type=self.lt),
         )
 
         # Assign custom field values for location 2
-        cls.locations[1]._custom_field_data = {
-            cls.cf_text.key: "bar",
-            cls.cf_integer.key: 456,
-            cls.cf_boolean.key: True,
-            cls.cf_date.key: "2020-01-02",
-            cls.cf_url.key: "http://example.com/2",
-            cls.cf_select.key: "Bar",
-            cls.cf_multi_select.key: ["Bar", "Baz"],
+        self.locations[1]._custom_field_data = {
+            self.cf_text.key: "bar",
+            self.cf_integer.key: 456,
+            self.cf_boolean.key: True,
+            self.cf_date.key: "2020-01-02",
+            self.cf_url.key: "http://example.com/2",
+            self.cf_select.key: "Bar",
+            self.cf_multi_select.key: ["Bar", "Baz"],
+            self.cf_markdown.key: "### Hello world!\n\n- Item 1\n- Item 2\n- Item 3",
+            self.cf_json.key: {"hello": "world"},
         }
         if "example_plugin" in settings.PLUGINS:
-            cls.locations[1]._custom_field_data[cls.cf_plugin_field.key] = "Custom value"
-        cls.locations[1].save()
+            self.locations[1]._custom_field_data[self.cf_plugin_field.key] = "Custom value"
+        self.locations[1].validated_save()
+        self.list_url = reverse("dcim-api:location-list")
+        self.detail_url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
 
     def test_get_single_object_without_custom_field_data(self):
         """
         Validate that custom fields are present on an object even if it has no values defined.
         """
         url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[0].pk})
-        self.add_permissions("dcim.view_location")
 
         response = self.client.get(url, **self.header)
         self.assertEqual(response.data["name"], self.locations[0].name)
         # A model directly instantiated via the ORM does NOT automatically receive custom field default values.
         # This is arguably a bug. See https://github.com/nautobot/nautobot/issues/3312 for details.
-        expected_data = {
-            "text_cf": None,
-            "number_cf": None,
-            "boolean_cf": None,
-            "date_cf": None,
-            "url_cf": None,
-            "choice_cf": None,
-            "multi_choice_cf": None,
-        }
-        if "example_plugin" in settings.PLUGINS:
-            expected_data["example_plugin_auto_custom_field"] = None
+        expected_data = {cf.key: None for cf in self.all_cfs}
         self.assertEqual(response.data["custom_fields"], expected_data)
 
     def test_get_single_object_with_custom_field_data(self):
@@ -584,18 +622,12 @@ def test_get_single_object_with_custom_field_data(self):
         Validate that custom fields are present and correctly set for an object with values defined.
         """
         location2_cfvs = self.locations[1].cf
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.view_location")
-
-        response = self.client.get(url, **self.header)
+        response = self.client.get(self.detail_url, **self.header)
         self.assertEqual(response.data["name"], self.locations[1].name)
-        self.assertEqual(response.data["custom_fields"]["text_cf"], location2_cfvs["text_cf"])
-        self.assertEqual(response.data["custom_fields"]["number_cf"], location2_cfvs["number_cf"])
-        self.assertEqual(response.data["custom_fields"]["boolean_cf"], location2_cfvs["boolean_cf"])
-        self.assertEqual(response.data["custom_fields"]["date_cf"], location2_cfvs["date_cf"])
-        self.assertEqual(response.data["custom_fields"]["url_cf"], location2_cfvs["url_cf"])
-        self.assertEqual(response.data["custom_fields"]["choice_cf"], location2_cfvs["choice_cf"])
-        self.assertEqual(response.data["custom_fields"]["multi_choice_cf"], location2_cfvs["multi_choice_cf"])
+        for cf in self.all_cfs:
+            self.assertIn(cf.key, response.data["custom_fields"])
+            self.assertIn(cf.key, location2_cfvs)
+            self.assertEqual(response.data["custom_fields"][cf.key], location2_cfvs[cf.key])
 
     def test_create_single_object_with_defaults(self):
         """
@@ -606,35 +638,20 @@ def test_create_single_object_with_defaults(self):
             "location_type": self.lt.pk,
             "status": self.statuses[0].pk,
         }
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
 
         # Validate response data
         response_cf = response.data["custom_fields"]
-        self.assertEqual(response_cf["text_cf"], self.cf_text.default)
-        self.assertEqual(response_cf["number_cf"], self.cf_integer.default)
-        self.assertEqual(response_cf["boolean_cf"], self.cf_boolean.default)
-        self.assertEqual(response_cf["date_cf"], self.cf_date.default)
-        self.assertEqual(response_cf["url_cf"], self.cf_url.default)
-        self.assertEqual(response_cf["choice_cf"], self.cf_select.default)
-        self.assertEqual(response_cf["multi_choice_cf"], self.cf_multi_select.default)
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(response_cf["example_plugin_auto_custom_field"], self.cf_plugin_field.default)
+        for cf in self.all_cfs:
+            self.assertIn(cf.key, response_cf)
+            self.assertEqual(response_cf[cf.key], cf.default)
 
         # Validate database data
         location = Location.objects.get(pk=response.data["id"])
-        self.assertEqual(location.cf["text_cf"], self.cf_text.default)
-        self.assertEqual(location.cf["number_cf"], self.cf_integer.default)
-        self.assertEqual(location.cf["boolean_cf"], self.cf_boolean.default)
-        self.assertEqual(str(location.cf["date_cf"]), self.cf_date.default)
-        self.assertEqual(location.cf["url_cf"], self.cf_url.default)
-        self.assertEqual(location.cf["choice_cf"], self.cf_select.default)
-        self.assertEqual(location.cf["multi_choice_cf"], self.cf_multi_select.default)
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(location.cf["example_plugin_auto_custom_field"], self.cf_plugin_field.default)
+        for cf in self.all_cfs:
+            self.assertIn(cf.key, location.cf)
+            self.assertEqual(location.cf[cf.key], cf.default)
 
     def test_create_single_object_with_values(self):
         """
@@ -645,51 +662,35 @@ def test_create_single_object_with_values(self):
             "status": self.statuses[0].pk,
             "location_type": self.lt.pk,
             "custom_fields": {
-                "text_cf": "bar",
-                "number_cf": 456,
-                "boolean_cf": True,
-                "date_cf": "2020-01-02",
-                "url_cf": "http://example.com/2",
-                "choice_cf": "Bar",
-                "multi_choice_cf": ["Baz"],
+                self.cf_text.key: "bar",
+                self.cf_integer.key: 456,
+                self.cf_boolean.key: True,
+                self.cf_date.key: "2020-01-02",
+                self.cf_url.key: "http://example.com/2",
+                self.cf_select.key: "Bar",
+                self.cf_multi_select.key: ["Baz"],
+                self.cf_markdown.key: "[hello](http://example.com)",
+                self.cf_json.key: {"foo": "bar"},
             },
         }
         if "example_plugin" in settings.PLUGINS:
             data["custom_fields"]["example_plugin_auto_custom_field"] = "Custom value"
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
 
         # Validate response data
         response_cf = response.data["custom_fields"]
         data_cf = data["custom_fields"]
-        self.assertEqual(response_cf["text_cf"], data_cf["text_cf"])
-        self.assertEqual(response_cf["number_cf"], data_cf["number_cf"])
-        self.assertEqual(response_cf["boolean_cf"], data_cf["boolean_cf"])
-        self.assertEqual(response_cf["date_cf"], data_cf["date_cf"])
-        self.assertEqual(response_cf["url_cf"], data_cf["url_cf"])
-        self.assertEqual(response_cf["choice_cf"], data_cf["choice_cf"])
-        self.assertEqual(response_cf["multi_choice_cf"], data_cf["multi_choice_cf"])
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(
-                response_cf["example_plugin_auto_custom_field"], data_cf["example_plugin_auto_custom_field"]
-            )
+        for cf in self.all_cfs:
+            self.assertIn(cf.key, response_cf)
+            self.assertIn(cf.key, data_cf)
+            self.assertEqual(response_cf[cf.key], data_cf[cf.key])
 
         # Validate database data
         location = Location.objects.get(pk=response.data["id"])
-        self.assertEqual(location.cf["text_cf"], data_cf["text_cf"])
-        self.assertEqual(location.cf["number_cf"], data_cf["number_cf"])
-        self.assertEqual(location.cf["boolean_cf"], data_cf["boolean_cf"])
-        self.assertEqual(str(location.cf["date_cf"]), data_cf["date_cf"])
-        self.assertEqual(location.cf["url_cf"], data_cf["url_cf"])
-        self.assertEqual(location.cf["choice_cf"], data_cf["choice_cf"])
-        self.assertEqual(location.cf["multi_choice_cf"], data_cf["multi_choice_cf"])
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(
-                location.cf["example_plugin_auto_custom_field"], data_cf["example_plugin_auto_custom_field"]
-            )
+        for cf in self.all_cfs:
+            self.assertIn(cf.key, location.cf)
+            self.assertEqual(location.cf[cf.key], data_cf[cf.key])
 
     def test_create_multiple_objects_with_defaults(self):
         """
@@ -713,50 +714,37 @@ def test_create_multiple_objects_with_defaults(self):
                 "status": self.statuses[0].pk,
             },
         )
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(len(response.data), len(data))
 
         for i, _obj in enumerate(data):
             # Validate response data
             response_cf = response.data[i]["custom_fields"]
-            self.assertEqual(response_cf["text_cf"], self.cf_text.default)
-            self.assertEqual(response_cf["number_cf"], self.cf_integer.default)
-            self.assertEqual(response_cf["boolean_cf"], self.cf_boolean.default)
-            self.assertEqual(response_cf["date_cf"], self.cf_date.default)
-            self.assertEqual(response_cf["url_cf"], self.cf_url.default)
-            self.assertEqual(response_cf["choice_cf"], self.cf_select.default)
-            self.assertEqual(response_cf["multi_choice_cf"], self.cf_multi_select.default)
-            if "example_plugin" in settings.PLUGINS:
-                self.assertEqual(response_cf["example_plugin_auto_custom_field"], self.cf_plugin_field.default)
+            for cf in self.all_cfs:
+                self.assertIn(cf.key, response_cf)
+                self.assertEqual(response_cf[cf.key], cf.default)
 
             # Validate database data
             location = Location.objects.get(pk=response.data[i]["id"])
-            self.assertEqual(location.cf["text_cf"], self.cf_text.default)
-            self.assertEqual(location.cf["number_cf"], self.cf_integer.default)
-            self.assertEqual(location.cf["boolean_cf"], self.cf_boolean.default)
-            self.assertEqual(str(location.cf["date_cf"]), self.cf_date.default)
-            self.assertEqual(location.cf["url_cf"], self.cf_url.default)
-            self.assertEqual(location.cf["choice_cf"], self.cf_select.default)
-            self.assertEqual(location.cf["multi_choice_cf"], self.cf_multi_select.default)
-            if "example_plugin" in settings.PLUGINS:
-                self.assertEqual(location.cf["example_plugin_auto_custom_field"], self.cf_plugin_field.default)
+            for cf in self.all_cfs:
+                self.assertIn(cf.key, location.cf)
+                self.assertEqual(location.cf[cf.key], cf.default)
 
     def test_create_multiple_objects_with_values(self):
         """
         Create a three new locations, each with custom fields defined.
         """
         custom_field_data = {
-            "text_cf": "bar",
-            "number_cf": 456,
-            "boolean_cf": True,
-            "date_cf": "2020-01-02",
-            "url_cf": "http://example.com/2",
-            "choice_cf": "Bar",
-            "multi_choice_cf": ["Foo", "Bar"],
+            self.cf_text.key: "bar",
+            self.cf_integer.key: 456,
+            self.cf_boolean.key: True,
+            self.cf_date.key: "2020-01-02",
+            self.cf_url.key: "http://example.com/2",
+            self.cf_select.key: "Bar",
+            self.cf_multi_select.key: ["Foo", "Bar"],
+            self.cf_markdown.key: "### Heading",
+            self.cf_json.key: {"dict1": {"dict2": {}}},
         }
         if "example_plugin" in settings.PLUGINS:
             custom_field_data["example_plugin_auto_custom_field"] = "Custom value"
@@ -780,43 +768,23 @@ def test_create_multiple_objects_with_values(self):
                 "custom_fields": custom_field_data,
             },
         )
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(len(response.data), len(data))
 
         for i, _obj in enumerate(data):
             # Validate response data
             response_cf = response.data[i]["custom_fields"]
-            self.assertEqual(response_cf["text_cf"], custom_field_data["text_cf"])
-            self.assertEqual(response_cf["number_cf"], custom_field_data["number_cf"])
-            self.assertEqual(response_cf["boolean_cf"], custom_field_data["boolean_cf"])
-            self.assertEqual(response_cf["date_cf"], custom_field_data["date_cf"])
-            self.assertEqual(response_cf["url_cf"], custom_field_data["url_cf"])
-            self.assertEqual(response_cf["choice_cf"], custom_field_data["choice_cf"])
-            self.assertEqual(response_cf["multi_choice_cf"], custom_field_data["multi_choice_cf"])
-            if "example_plugin" in settings.PLUGINS:
-                self.assertEqual(
-                    response_cf["example_plugin_auto_custom_field"],
-                    custom_field_data["example_plugin_auto_custom_field"],
-                )
+            for cf in self.all_cfs:
+                self.assertIn(cf.key, response_cf)
+                self.assertIn(cf.key, custom_field_data)
+                self.assertEqual(response_cf[cf.key], custom_field_data[cf.key])
 
             # Validate database data
             location = Location.objects.get(pk=response.data[i]["id"])
-            self.assertEqual(location.cf["text_cf"], custom_field_data["text_cf"])
-            self.assertEqual(location.cf["number_cf"], custom_field_data["number_cf"])
-            self.assertEqual(location.cf["boolean_cf"], custom_field_data["boolean_cf"])
-            self.assertEqual(str(location.cf["date_cf"]), custom_field_data["date_cf"])
-            self.assertEqual(location.cf["url_cf"], custom_field_data["url_cf"])
-            self.assertEqual(location.cf["choice_cf"], custom_field_data["choice_cf"])
-            self.assertEqual(location.cf["multi_choice_cf"], custom_field_data["multi_choice_cf"])
-            if "example_plugin" in settings.PLUGINS:
-                self.assertEqual(
-                    location.cf["example_plugin_auto_custom_field"],
-                    custom_field_data["example_plugin_auto_custom_field"],
-                )
+            for cf in self.all_cfs:
+                self.assertIn(cf.key, location.cf)
+                self.assertEqual(location.cf[cf.key], custom_field_data[cf.key])
 
     def test_update_single_object_with_values(self):
         """
@@ -827,114 +795,150 @@ def test_update_single_object_with_values(self):
         original_cfvs = {**location.cf}
         data = {
             "custom_fields": {
-                "text_cf": "ABCD",
-                "number_cf": 1234,
+                self.cf_text.key: "ABCD",
+                self.cf_integer.key: 1234,
             },
         }
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.change_location")
-
-        response = self.client.patch(url, data, format="json", **self.header)
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
         # Validate response data
         response_cf = response.data["custom_fields"]
-        self.assertEqual(response_cf["text_cf"], data["custom_fields"]["text_cf"])
-        self.assertEqual(response_cf["number_cf"], data["custom_fields"]["number_cf"])
-        self.assertEqual(response_cf["boolean_cf"], original_cfvs["boolean_cf"])
-        self.assertEqual(response_cf["date_cf"], original_cfvs["date_cf"])
-        self.assertEqual(response_cf["url_cf"], original_cfvs["url_cf"])
-        self.assertEqual(response_cf["choice_cf"], original_cfvs["choice_cf"])
-        self.assertEqual(response_cf["multi_choice_cf"], original_cfvs["multi_choice_cf"])
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(
-                response_cf["example_plugin_auto_custom_field"], original_cfvs["example_plugin_auto_custom_field"]
-            )
+        for cf in self.all_cfs:
+            if cf.key in data["custom_fields"]:
+                self.assertEqual(response_cf[cf.key], data["custom_fields"][cf.key])
+            else:
+                self.assertEqual(response_cf[cf.key], original_cfvs[cf.key])
 
         # Validate database data
         location.refresh_from_db()
-        self.assertEqual(location.cf["text_cf"], data["custom_fields"]["text_cf"])
-        self.assertEqual(
-            location.cf["number_cf"],
-            data["custom_fields"]["number_cf"],
-        )
-        self.assertEqual(location.cf["boolean_cf"], original_cfvs["boolean_cf"])
-        self.assertEqual(location.cf["date_cf"], original_cfvs["date_cf"])
-        self.assertEqual(location.cf["url_cf"], original_cfvs["url_cf"])
-        self.assertEqual(location.cf["choice_cf"], original_cfvs["choice_cf"])
-        self.assertEqual(location.cf["multi_choice_cf"], original_cfvs["multi_choice_cf"])
-        if "example_plugin" in settings.PLUGINS:
-            self.assertEqual(
-                location.cf["example_plugin_auto_custom_field"], original_cfvs["example_plugin_auto_custom_field"]
-            )
-
-    def test_minimum_maximum_values_validation(self):
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.change_location")
+        for cf in self.all_cfs:
+            if cf.key in data["custom_fields"]:
+                self.assertEqual(location.cf[cf.key], data["custom_fields"][cf.key])
+            else:
+                self.assertEqual(location.cf[cf.key], original_cfvs[cf.key])
 
+    def test_integer_minimum_maximum_values_validation(self):
         self.cf_integer.validation_minimum = 10
         self.cf_integer.validation_maximum = 20
         self.cf_integer.save()
 
-        data = {"custom_fields": {"number_cf": 9}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: 9}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-        data = {"custom_fields": {"number_cf": 21}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: 21}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-        data = {"custom_fields": {"number_cf": 15}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: 15}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-    def test_bigint_values_of_custom_field_maximum_attribute(self):
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.change_location")
-
+    def test_integer_bigint_values_of_custom_field_maximum_attribute(self):
         self.cf_integer.validation_maximum = 5000000000
         self.cf_integer.save()
 
-        data = {"custom_fields": {"number_cf": 4294967294}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: 4294967294}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-        data = {"custom_fields": {"number_cf": 5000000001}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: 5000000001}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-    def test_bigint_values_of_custom_field_minimum_attribute(self):
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.change_location")
-
+    def test_integer_bigint_values_of_custom_field_minimum_attribute(self):
         self.cf_integer.validation_minimum = -5000000000
         self.cf_integer.save()
 
-        data = {"custom_fields": {"number_cf": -4294967294}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: -4294967294}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-        data = {"custom_fields": {"number_cf": -5000000001}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_integer.key: -5000000001}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-    def test_regex_validation(self):
-        url = reverse("dcim-api:location-detail", kwargs={"pk": self.locations[1].pk})
-        self.add_permissions("dcim.change_location")
+    def test_text_minimum_maximum_length_validation(self):
+        # No minimum or maximum length by default
+        data = {
+            "custom_fields": {
+                self.cf_text.key: "",
+                self.cf_url.key: "",
+                self.cf_json.key: "",
+                self.cf_markdown.key: "",
+            }
+        }
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        data = {
+            "custom_fields": {
+                self.cf_text.key: "a" * 500,
+                self.cf_url.key: "b" * 500,
+                self.cf_json.key: "c" * 500,
+                self.cf_markdown.key: "d" * 500,
+            }
+        }
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        for cf in [self.cf_text, self.cf_url, self.cf_json, self.cf_markdown]:
+            if cf != self.cf_json:
+                cf.validation_minimum = len(cf.default)
+                invalid_value = cf.default[:-1]
+            else:
+                cf.validation_minimum = len(json.dumps(cf.default))
+                invalid_value = {}
+            cf.validated_save()
+
+            try:
+                invalid_data = {"custom_fields": {cf.key: invalid_value}}
+                response = self.client.patch(self.detail_url, invalid_data, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+                valid_data = {"custom_fields": {cf.key: cf.default}}
+                response = self.client.patch(self.detail_url, valid_data, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+            finally:
+                cf.validation_minimum = None
+                cf.validated_save()
+
+        for cf in [self.cf_text, self.cf_url, self.cf_json, self.cf_markdown]:
+            if cf != self.cf_json:
+                cf.validation_maximum = len(cf.default)
+                invalid_value = cf.default + "1"
+            else:
+                cf.validation_maximum = len(json.dumps(cf.default))
+                invalid_value = json.dumps(cf.default) + "1"
+            cf.validated_save()
+
+            try:
+                invalid_data = {"custom_fields": {cf.key: invalid_value}}
+                response = self.client.patch(self.detail_url, invalid_data, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+                valid_data = {"custom_fields": {cf.key: cf.default}}
+                response = self.client.patch(self.detail_url, valid_data, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+            finally:
+                cf.validation_maximum = None
+                cf.validated_save()
 
+    def test_regex_validation(self):
         self.cf_text.validation_regex = r"^[A-Z]{3}$"  # Three uppercase letters
         self.cf_text.save()
 
-        data = {"custom_fields": {"text_cf": "ABC123"}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_text.key: "ABC123"}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-        data = {"custom_fields": {"text_cf": "abc"}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_text.key: "abc"}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-        data = {"custom_fields": {"text_cf": "ABC"}}
-        response = self.client.patch(url, data, format="json", **self.header)
+        data = {"custom_fields": {self.cf_text.key: "ABC"}}
+        response = self.client.patch(self.detail_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
     def test_select_regex_validation(self):
@@ -956,6 +960,26 @@ def test_select_regex_validation(self):
         response = self.client.post(url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
 
+    def test_select_minimum_maximum_validation(self):
+        url = reverse("extras-api:customfieldchoice-list")
+        self.add_permissions("extras.add_customfieldchoice")
+
+        self.cf_select.validation_minimum = len(self.cf_select.default)
+        self.cf_select.validation_maximum = len(self.cf_select.default)
+        self.cf_select.save()
+
+        data = {"custom_field": self.cf_select.id, "value": self.cf_select.default[:-1], "weight": 100}
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        data = {"custom_field": self.cf_select.id, "value": self.cf_select.default + "A", "weight": 100}
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        data = {"custom_field": self.cf_select.id, "value": "q" * len(self.cf_select.default), "weight": 100}
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
     def test_text_type_with_invalid_values(self):
         """
         Try and create a new location with an invalid value for a text type.
@@ -965,23 +989,20 @@ def test_text_type_with_invalid_values(self):
             "status": self.statuses[0].pk,
             "location_type": self.lt.pk,
             "custom_fields": {
-                "text_cf": ["I", "am", "a", "disallowed", "type"],
+                self.cf_text.key: ["I", "am", "a", "disallowed", "type"],
             },
         }
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Value must be a string", str(response.content))
 
-        data["custom_fields"].update({"text_cf": 2})
-        response = self.client.post(url, data, format="json", **self.header)
+        data["custom_fields"].update({self.cf_text.key: 2})
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Value must be a string", str(response.content))
 
-        data["custom_fields"].update({"text_cf": True})
-        response = self.client.post(url, data, format="json", **self.header)
+        data["custom_fields"].update({self.cf_text.key: True})
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Value must be a string", str(response.content))
 
@@ -995,9 +1016,7 @@ def test_create_without_required_field(self):
             "location_type": self.lt.pk,
             "status": self.statuses[0].pk,
         }
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Required field cannot be empty", str(response.content))
 
@@ -1008,7 +1027,7 @@ def test_create_without_required_field(self):
                 f"Location N,{self.lt.composite_key},{self.statuses[0].name}",
             ]
         )
-        response = self.client.post(url, csvdata, content_type="text/csv", **self.header)
+        response = self.client.post(self.list_url, csvdata, content_type="text/csv", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Required field cannot be empty", str(response.content))
 
@@ -1018,12 +1037,10 @@ def test_create_invalid_select_choice(self):
             "location_type": self.lt.pk,
             "status": self.statuses[0].pk,
             "custom_fields": {
-                "choice_cf": "Frobozz",
+                self.cf_select.key: "Frobozz",
             },
         }
-        url = reverse("dcim-api:location-list")
-        self.add_permissions("dcim.add_location")
-        response = self.client.post(url, data, format="json", **self.header)
+        response = self.client.post(self.list_url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Invalid choice", str(response.content))
 
@@ -1034,7 +1051,7 @@ def test_create_invalid_select_choice(self):
                 f"Location N,{self.lt.composite_key},{self.statuses[0].name},Frobozz",
             ]
         )
-        response = self.client.post(url, csvdata, content_type="text/csv", **self.header)
+        response = self.client.post(self.list_url, csvdata, content_type="text/csv", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertIn("Invalid choice", str(response.content))