Repository: netbox-community/netbox
Pull request: Fixes: #13682 - Fix custom field exceptions and validation
### Fixes: #13682 

Fix custom field exceptions and validation

* Change self.message to err.message to properly reference nested exception
* Change clean logic to set "default_value" to list for multiselect and multiobject
* Change validation logic to deconstruct choice set tuple for select to only check the value

Problem statement:
Cannot add a new custom field of type selection/multiple selection list
### NetBox version

v3.6.0

### Python version

3.9

### Steps to Reproduce

1. Add a choice field set named Test with the following choices:
    ````
    test,Test
    tester,Tester
    testing,Testing
    ````
3. Add a custom field
    a. Name: Test
    b: Object: Circuits
    c: Type: Multiple Selection (Selection also has an issue)
    d: Required: True
    e: Default: "test"
    f: Choice Set: Test
4. Save

### Expected Behavior

Custom Field Saves

### Observed Behavior


`AttributeError: 'CustomField' object has no attribute 'message'`


Nested Exception: 
`ValidationError: Invalid choice(s) (t, e, s, t). Available choices are: `


Alternatively, if default is set to "", the nested exception is:

`ValidationError: 'Required field cannot be empty.'`

Hints from issue discussion:
Turns out, the list was added wrong on my end my end as well, resulting in some of the problems.  Updated to reflect the problem more specifically.

That said, there is an issue with the validation still of a default field.  `self.default and self.default not in self.choices:` won't work

We could actually remove line 321 to 326 as that validation logic is also part of the default check at the top.

Commit message: Fixes: #13682 - Fix custom field exceptions and validation

Patch to review:
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index ac68855a0ea..353a90aec22 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -275,6 +275,8 @@ def clean(self):
             try:
                 if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
                     default_value = str(self.default)
+                elif self.type in (CustomFieldTypeChoices.TYPE_MULTISELECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
+                    default_value = [self.default]
                 else:
                     default_value = self.default
                 self.validate(default_value)
@@ -282,7 +284,7 @@ def clean(self):
                 raise ValidationError({
                     'default': _(
                         'Invalid default value "{default}": {message}'
-                    ).format(default=self.default, message=self.message)
+                    ).format(default=self.default, message=err.message)
                 })
 
         # Minimum/maximum values can be set only for numeric fields
@@ -318,7 +320,8 @@ def clean(self):
             })
 
         # A selection field's default (if any) must be present in its available choices
-        if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
+        if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and \
+                self.default not in [c[0] for c in self.choices]:
             raise ValidationError({
                 'default': _(
                     "The specified default value ({default}) is not listed as an available choice."
@@ -653,7 +656,7 @@ def validate(self, value):
                 if value not in [c[0] for c in self.choices]:
                     raise ValidationError(
                         _("Invalid choice ({value}). Available choices are: {choices}").format(
-                            value=value, choices=', '.join(self.choices)
+                            value=value, choices=', '.join([c[0] for c in self.choices])
                         )
                     )
 
@@ -662,7 +665,7 @@ def validate(self, value):
                 if not set(value).issubset([c[0] for c in self.choices]):
                     raise ValidationError(
                         _("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
-                            invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
+                            invalid_choices=', '.join(value), available_choices=', '.join([c[0] for c in self.choices]))
                     )
 
             # Validate selected object


Code review comments:
diff_hunk: @@ -653,7 +656,7 @@ def validate(self, value):
                 if value not in [c[0] for c in self.choices]:
                     raise ValidationError(
                         _("Invalid choice ({value}). Available choices are: {choices}").format(
-                            value=value, choices=', '.join(self.choices)
+                            value=value, choices=', '.join([c[0] for c in self.choices])
path: netbox/extras/models/customfields.py
text: @user1:
We don't want to return _all_ possible choices here, which could number in the thousands. (This was a problem in the original code.) Instead, we should return a slice with just the first few options to convey a pattern to the user, and annotate the total number of choices.

@author:
I think I captured this decently enough

diff_hunk: @@ -275,14 +275,16 @@ def clean(self):
             try:
                 if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
                     default_value = str(self.default)
+                elif self.type in (CustomFieldTypeChoices.TYPE_MULTISELECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
+                    default_value = [self.default]
path: netbox/extras/models/customfields.py
text: @user1:
The default value should be provided in this form; i.e. `"test"` is not a valid default value for a multi-select field. The correct default value would be `["test"]`, because that's the native form of the data.