Repository: netbox-community/netbox

Issue:
Values for Selection Custom Fields via API
<!--
    Before opening a new issue, please search through the existing issues to
    see if your topic has already been addressed. Note that you may need to
    remove the "is:open" filter from the search bar to include closed issues.

    Check the appropriate type for your issue below by placing an x between the
    brackets. For assistance with installation issues, or for any other issues
    other than those listed below, please raise your topic for discussion on
    our mailing list:

        https://groups.google.com/forum/#!forum/netbox-discuss

    Please note that issues which do not fall under any of the below categories
    will be closed. Due to an excessive backlog of feature requests, we are
    not currently accepting any proposals which extend NetBox's feature scope.

    Do not prepend any sort of tag to your issue's title. An administrator will
    review your issue and assign labels as appropriate.
--->
### Issue type
[X] Feature request <!-- An enhancement of existing functionality -->
[ ] Bug report      <!-- Unexpected or erroneous behavior -->
[ ] Documentation   <!-- A modification to the documentation -->

<!--
    Please describe the environment in which you are running NetBox. (Be sure
    to verify that you are running the latest stable release of NetBox before
    submitting a bug report.) If you are submitting a bug report and have made
    any changes to the code base, please first validate that your bug can be
    recreated while running an official release.
-->
### Environment
* Python version:  3.6
* NetBox version:  2.2.8

<!--
    BUG REPORTS must include:
        * A list of the steps needed for someone else to reproduce the bug
        * A description of the expected and observed behavior
        * Any relevant error messages (screenshots may also help)

    FEATURE REQUESTS must include:
        * A detailed description of the proposed functionality
        * A use case for the new feature
        * A rough description of any necessary changes to the database schema
        * Any relevant third-party libraries which would be needed
-->
### Description

As I [already told on the mailing list](https://groups.google.com/forum/#!topic/netbox-discuss/4j7ODlKWNk0), I would like to fill in the values of _selection custom fields_ via the API.

I expected to provide the actual value of the field, i.e. the text I see in the dropdown of the GUI. But the API expects to get the ID of the option, rather than the value, and there seems to be no way of looking up the available (value,id)-pairs for a given _selection custom fields_. Also I would like to avoid hardcoding any (value,id)-pairs in my application, as it's not consistent across our different Netbox instances (local, staging, production) and it would be very fragile.

I see two solutions:
* When I set the value of such a selection custom field, Netbox accepts the String value and converts it to the respective ID internally.
* There is a new endpoint to fetch the option pairs of selection custom fields, similar to the other __options_ endpoints.

IMO both options could even live in parallel to each other: Whenever an integer is presented as value for a selection custom field, we can assume it's the ID of the value. Whenever it's a string, we can assume it's the text value and first has to be converted to the corresponding integer id.

Allowing to submit text values via the API instead of the actual IDs might imply that we don't allow the same text value on one given selection custom field twice.
  


Discussion:
I just learned that the behaviour I described was already discussed in https://github.com/digitalocean/netbox/issues/1297, yet no solution was proposed / implemented to get the primary keys of values via the API.

Which would be important when the selection options change over time or when the PKs differ between independant instances of Netbox (e.g. staging/production).
I ran into this same issue when trying to update a selection-based custom field I'm using through APIs (and [pynetbox](https://github.com/digitalocean/pynetbox)). I'm really trying to avoid custom fields whenever I can but I still need a few. In general I'd rather have a custom field with fixed selection options rather than a string to try to limit data diversity.. but without a programmatic way of changing selection values, it's getting a bit challenging.. 
We need to decide how to expose the custom field choices in the API. Currently, each app has a `_choices` endpoint which returns the choices for all models in the form `"model:field": [choices]`:

```
{
    "device:face": [
        {
            "label": "Front",
            "value": 0
        },
        {
            "label": "Rear",
            "value": 1
        }
    ],
    "device:status": [
        {
            "label": "Active",
            "value": 1
        },
        ...
    ]
}
```

We can't do `model:custom_field_name` because it's possible that `custom_field_name` will collide with the name of a built-in field. We need a way to distinguish custom fields.

One option would be to prepend `cf_` to the field name, such that custom field `foo` on the Device model would become `device:cf_foo`, but that may not be the cleanest approach. Open to suggestions.
I am also very much interested in this feature. 
I feel this should be its own API endpoint. Perhaps `/extras/custom-fields/_choices/`. This way it can act just like the built-in app choices endpoints, but there can be no name collision or name manipulation. It is also very apparent that these choices apply only to custom fields.
@jeremystretch I'd be fine with any way to lookup the '_choices' label using the value. Doesn't have to be pretty, just has to work.

Resolving patch:
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -17,6 +17,9 @@ def get_view_name(self):
 # Field choices
 router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 
+# Custom field choices
+router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
+
 # Graphs
 router.register(r'graphs', views.GraphViewSet)
 
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -1,3 +1,5 @@
+from collections import OrderedDict
+
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count
 from django.http import Http404, HttpResponse
@@ -9,8 +11,8 @@
 
 from extras import filters
 from extras.models import (
-    ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
-    Tag
+    ConfigContext, CustomField, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag,
 )
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -28,6 +30,36 @@ class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
     )
 
 
+#
+# Custom field choices
+#
+
+class CustomFieldChoicesViewSet(ViewSet):
+    """
+    """
+    permission_classes = [IsAuthenticatedOrLoginNotRequired]
+
+    def __init__(self, *args, **kwargs):
+        super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
+
+        self._fields = OrderedDict()
+
+        for cfc in CustomFieldChoice.objects.all():
+            self._fields.setdefault(cfc.field.name, {})
+            self._fields[cfc.field.name][cfc.value] = cfc.pk
+
+    def list(self, request):
+        return Response(self._fields)
+
+    def retrieve(self, request, pk):
+        if pk not in self._fields:
+            raise Http404
+        return Response(self._fields[pk])
+
+    def get_view_name(self):
+        return "Custom Field choices"
+
+
 #
 # Custom fields
 #


Test patch:
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -6,9 +6,10 @@
 from rest_framework import status
 
 from dcim.models import Site
-from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
+from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 from utilities.testing import APITestCase
+from virtualization.models import VirtualMachine
 
 
 class CustomFieldTest(TestCase):
@@ -299,3 +300,33 @@ def test_set_custom_field_select(self):
         self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
+
+
+class CustomFieldChoiceAPITest(APITestCase):
+    def setUp(self):
+        super().setUp()
+
+        vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
+
+        self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT)
+        self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT)
+
+        self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
+        self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
+        self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
+
+    def test_list_cfc(self):
+        url = reverse('extras-api:custom-field-choice-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(len(response.data), 2)
+        self.assertEqual(len(response.data[self.cf_1.name]), 2)
+        self.assertEqual(len(response.data[self.cf_2.name]), 1)
+
+        self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
+        self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
+        self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
+
+        self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
+        self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
+        self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])