Преглед изворни кода

Merge pull request #2688 from digitalocean/develop

Release v2.5.1
Jeremy Stretch пре 7 година
родитељ
комит
27a893a9a1

+ 21 - 0
CHANGELOG.md

@@ -1,3 +1,24 @@
+v2.5.1 (2018-12-13)
+
+## Enhancements
+
+* [#2655](https://github.com/digitalocean/netbox/issues/2655) - Add 128GFC Fibrechannel interface type
+* [#2674](https://github.com/digitalocean/netbox/issues/2674) - Enable filtering changelog by object type under web UI
+
+## Bug Fixes
+
+* [#2662](https://github.com/digitalocean/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs
+* [#2663](https://github.com/digitalocean/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view
+* [#2666](https://github.com/digitalocean/netbox/issues/2666) - Correct display of length unit in cables list
+* [#2676](https://github.com/digitalocean/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField
+* [#2678](https://github.com/digitalocean/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission
+* [#2680](https://github.com/digitalocean/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint
+* [#2683](https://github.com/digitalocean/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
+* [#2684](https://github.com/digitalocean/netbox/issues/2684) - Fix custom field filtering
+* [#2687](https://github.com/digitalocean/netbox/issues/2687) - Correct naming of before/after filters for changelog entries
+
+---
+
 v2.5.0 (2018-12-10)
 v2.5.0 (2018-12-10)
 
 
 ## Notes
 ## Notes

+ 47 - 1
docs/api/overview.md

@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
 }
 }
 ```
 ```
 
 
-Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.  
+Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
 
 
 When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
 When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
 
 
@@ -122,6 +122,52 @@ When a base serializer includes one or more nested serializers, the hierarchical
 }
 }
 ```
 ```
 
 
+## Brief Format
+
+Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
+
+For example, the default (complete) format of an IP address looks like this:
+
+```
+GET /api/ipam/prefixes/13980/
+
+{
+    "id": 13980,
+    "family": 4,
+    "prefix": "192.0.2.0/24",
+    "site": null,
+    "vrf": null,
+    "tenant": null,
+    "vlan": null,
+    "status": {
+        "value": 1,
+        "label": "Active"
+    },
+    "role": null,
+    "is_pool": false,
+    "description": "",
+    "tags": [],
+    "custom_fields": {},
+    "created": "2018-12-11",
+    "last_updated": "2018-12-11T16:27:55.073174-05:00"
+}
+```
+
+The brief format is much more terse, but includes a link to the object's full representation:
+
+```
+GET /api/ipam/prefixes/13980/?brief=1
+
+{
+    "id": 13980,
+    "url": "https://netbox/api/ipam/prefixes/13980/",
+    "family": 4,
+    "prefix": "192.0.2.0/24"
+}
+```
+
+The brief format is supported for both lists and individual objects.
+
 ## Static Choice Fields
 ## Static Choice Fields
 
 
 Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.
 Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.

+ 1 - 1
netbox/circuits/models.py

@@ -176,7 +176,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         unique_together = ['provider', 'cid']
         unique_together = ['provider', 'cid']
 
 
     def __str__(self):
     def __str__(self):
-        return '{} {}'.format(self.provider, self.cid)
+        return self.cid
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('circuits:circuit', args=[self.pk])
         return reverse('circuits:circuit', args=[self.pk])

+ 1 - 1
netbox/dcim/api/views.py

@@ -484,7 +484,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
     filterset_class = filters.PowerConnectionFilter
     filterset_class = filters.PowerConnectionFilter
 
 
 
 
-class InterfaceConnectionViewSet(ModelViewSet):
+class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
         'device', '_connected_interface', '_connected_circuittermination'
         'device', '_connected_interface', '_connected_circuittermination'
     ).filter(
     ).filter(

+ 2 - 0
netbox/dcim/constants.py

@@ -103,6 +103,7 @@ IFACE_FF_4GFC_SFP = 3040
 IFACE_FF_8GFC_SFP_PLUS = 3080
 IFACE_FF_8GFC_SFP_PLUS = 3080
 IFACE_FF_16GFC_SFP_PLUS = 3160
 IFACE_FF_16GFC_SFP_PLUS = 3160
 IFACE_FF_32GFC_SFP28 = 3320
 IFACE_FF_32GFC_SFP28 = 3320
+IFACE_FF_128GFC_QSFP28 = 3400
 # Serial
 # Serial
 IFACE_FF_T1 = 4000
 IFACE_FF_T1 = 4000
 IFACE_FF_E1 = 4010
 IFACE_FF_E1 = 4010
@@ -188,6 +189,7 @@ IFACE_FF_CHOICES = [
             [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
             [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
             [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
             [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
             [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
             [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
+            [IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
         ]
         ]
     ],
     ],
     [
     [

+ 12 - 7
netbox/dcim/models.py

@@ -110,11 +110,14 @@ class CableTermination(models.Model):
                     raise Exception("Invalid position for {} ({} positions): {})".format(
                     raise Exception("Invalid position for {} ({} positions): {})".format(
                         termination, termination.positions, position
                         termination, termination.positions, position
                     ))
                     ))
-                peer_port = FrontPort.objects.get(
-                    rear_port=termination,
-                    rear_port_position=position,
-                )
-                return peer_port, 1
+                try:
+                    peer_port = FrontPort.objects.get(
+                        rear_port=termination,
+                        rear_port_position=position,
+                    )
+                    return peer_port, 1
+                except ObjectDoesNotExist:
+                    return None, None
 
 
             # Follow a circuit to its other termination
             # Follow a circuit to its other termination
             elif isinstance(termination, CircuitTermination) and follow_circuits:
             elif isinstance(termination, CircuitTermination) and follow_circuits:
@@ -2629,5 +2632,7 @@ class Cable(ChangeLoggedModel):
                     path_status = CONNECTION_STATUS_PLANNED
                     path_status = CONNECTION_STATUS_PLANNED
                     break
                     break
 
 
-        # (A path end, B path end, connected/planned)
-        return a_path[-1][2], b_path[-1][2], path_status
+        a_endpoint = a_path[-1][2]
+        b_endpoint = b_path[-1][2]
+
+        return a_endpoint, b_endpoint, path_status

+ 1 - 1
netbox/dcim/signals.py

@@ -62,7 +62,7 @@ def nullify_connected_endpoints(instance, **kwargs):
         instance.termination_b.save()
         instance.termination_b.save()
 
 
     # If this Cable was part of a complete path, tear it down
     # If this Cable was part of a complete path, tear it down
-    if endpoint_a is not None and endpoint_b is not None:
+    if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
         endpoint_a.connected_endpoint = None
         endpoint_a.connected_endpoint = None
         endpoint_a.connection_status = None
         endpoint_a.connection_status = None
         endpoint_a.save()
         endpoint_a.save()

+ 1 - 1
netbox/dcim/tables.py

@@ -179,7 +179,7 @@ CABLE_TERMINATION_PARENT = """
 """
 """
 
 
 CABLE_LENGTH = """
 CABLE_LENGTH = """
-{% if record.length %}{{ record.length }}{{ record.length_unit }}{% else %}—{% endif %}
+{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
 """
 """
 
 
 
 

+ 2 - 1
netbox/extras/admin.py

@@ -30,7 +30,8 @@ class WebhookForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        order_content_types(self.fields['obj_type'])
+        if 'obj_type' in self.fields:
+            order_content_types(self.fields['obj_type'])
 
 
 
 
 @admin.register(Webhook, site=admin_site)
 @admin.register(Webhook, site=admin_site)

+ 4 - 4
netbox/extras/filters.py

@@ -31,12 +31,12 @@ class CustomFieldFilter(django_filters.Filter):
                 # Treat 0 as None
                 # Treat 0 as None
                 if int(value) == 0:
                 if int(value) == 0:
                     return queryset.exclude(
                     return queryset.exclude(
-                        custom_field_values__field__name=self.name,
+                        custom_field_values__field__name=self.field_name,
                     )
                     )
                 # Match on exact CustomFieldChoice PK
                 # Match on exact CustomFieldChoice PK
                 else:
                 else:
                     return queryset.filter(
                     return queryset.filter(
-                        custom_field_values__field__name=self.name,
+                        custom_field_values__field__name=self.field_name,
                         custom_field_values__serialized_value=value,
                         custom_field_values__serialized_value=value,
                     )
                     )
             except ValueError:
             except ValueError:
@@ -45,12 +45,12 @@ class CustomFieldFilter(django_filters.Filter):
         # Apply the assigned filter logic (exact or loose)
         # Apply the assigned filter logic (exact or loose)
         if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
         if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
             queryset = queryset.filter(
             queryset = queryset.filter(
-                custom_field_values__field__name=self.name,
+                custom_field_values__field__name=self.field_name,
                 custom_field_values__serialized_value=value
                 custom_field_values__serialized_value=value
             )
             )
         else:
         else:
             queryset = queryset.filter(
             queryset = queryset.filter(
-                custom_field_values__field__name=self.name,
+                custom_field_values__field__name=self.field_name,
                 custom_field_values__serialized_value__icontains=value
                 custom_field_values__serialized_value__icontains=value
             )
             )
 
 

+ 10 - 5
netbox/extras/forms.py

@@ -11,7 +11,7 @@ from taggit.models import Tag
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
+    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
     FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
     FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
 )
 )
 from .constants import (
 from .constants import (
@@ -307,21 +307,20 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 # Change logging
 # Change logging
 #
 #
 
 
-class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     model = ObjectChange
     model = ObjectChange
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    # TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
-    time_0 = forms.DateTimeField(
+    time_after = forms.DateTimeField(
         label='After',
         label='After',
         required=False,
         required=False,
         widget=forms.TextInput(
         widget=forms.TextInput(
             attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
             attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
         )
         )
     )
     )
-    time_1 = forms.DateTimeField(
+    time_before = forms.DateTimeField(
         label='Before',
         label='Before',
         required=False,
         required=False,
         widget=forms.TextInput(
         widget=forms.TextInput(
@@ -336,3 +335,9 @@ class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=User.objects.order_by('username'),
         queryset=User.objects.order_by('username'),
         required=False
         required=False
     )
     )
+    changed_object_type = forms.ModelChoiceField(
+        queryset=ContentType.objects.order_by('model'),
+        required=False,
+        widget=ContentTypeSelect(),
+        label='Object Type'
+    )

+ 1 - 1
netbox/ipam/models.py

@@ -812,7 +812,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         return Interface.objects.filter(
         return Interface.objects.filter(
             Q(untagged_vlan_id=self.pk) |
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
             Q(tagged_vlans=self.pk)
-        )
+        ).distinct()
 
 
 
 
 class Service(ChangeLoggedModel, CustomFieldModel):
 class Service(ChangeLoggedModel, CustomFieldModel):

+ 1 - 1
netbox/ipam/tables.py

@@ -430,7 +430,7 @@ class VLANDetailTable(VLANTable):
 
 
 class VLANMemberTable(BaseTable):
 class VLANMemberTable(BaseTable):
     parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
     parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
-    name = tables.Column(verbose_name='Interface')
+    name = tables.LinkColumn(verbose_name='Interface')
     untagged = tables.TemplateColumn(
     untagged = tables.TemplateColumn(
         template_code=VLAN_MEMBER_UNTAGGED,
         template_code=VLAN_MEMBER_UNTAGGED,
         orderable=False
         orderable=False

+ 1 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.5.0'
+VERSION = '2.5.1'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 1 - 1
netbox/templates/circuits/circuittermination_edit.html

@@ -10,7 +10,7 @@
         {% endfor %}
         {% endfor %}
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3">
             <div class="col-md-6 col-md-offset-3">
-                <h3>{% block title %}Circuit {{ obj.circuit }} - {{ form.term_side.value }} Side{% endblock %}</h3>
+                <h3>{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}</h3>
                 {% if form.non_field_errors %}
                 {% if form.non_field_errors %}
                     <div class="panel panel-danger">
                     <div class="panel panel-danger">
                         <div class="panel-heading"><strong>Errors</strong></div>
                         <div class="panel-heading"><strong>Errors</strong></div>

+ 1 - 1
netbox/templates/dcim/inc/cable_termination.html

@@ -29,7 +29,7 @@
         <tr>
         <tr>
             <td>Circuit</td>
             <td>Circuit</td>
             <td>
             <td>
-                <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> (Side {{ termination.term_side }})
+                <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> ({{ termination }})
             </td>
             </td>
         </tr>
         </tr>
     {% endif %}
     {% endif %}

+ 3 - 2
netbox/templates/dcim/inc/cable_trace_end.html

@@ -5,7 +5,7 @@
         {% if end.device %}
         {% if end.device %}
             <strong><a href="{{ end.device.get_absolute_url }}">{{ end.device }}</a></strong>
             <strong><a href="{{ end.device.get_absolute_url }}">{{ end.device }}</a></strong>
         {% else %}
         {% else %}
-            <strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong>
+            <strong><a href="{{ end.circuit.provider.get_absolute_url }}">{{ end.circuit.provider }}</a></strong>
         {% endif %}
         {% endif %}
     </div>
     </div>
     <div class="panel-body text-center">
     <div class="panel-body text-center">
@@ -21,7 +21,8 @@
             {% endwith %}
             {% endwith %}
         {% else %}
         {% else %}
             {# Circuit termination #}
             {# Circuit termination #}
-            <strong>Side {{ end.term_side }}</strong>
+            <strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong><br/>
+            {{ end }}
         {% endif %}
         {% endif %}
     </div>
     </div>
 </div>
 </div>

+ 29 - 7
netbox/templates/dcim/inc/interface.html

@@ -75,10 +75,16 @@
     {% elif iface.connected_endpoint.name %}
     {% elif iface.connected_endpoint.name %}
         {# Connected to an Interface #}
         {# Connected to an Interface #}
         <td>
         <td>
-            <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
+            <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">
+                {{ iface.connected_endpoint.device }}
+            </a>
         </td>
         </td>
         <td>
         <td>
-            <a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}"><span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span></a>
+            <a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}">
+                <span title="{{ iface.connected_endpoint.get_form_factor_display }}">
+                    {{ iface.connected_endpoint }}
+                </span>
+            </a>
         </td>
         </td>
     {% elif iface.connected_endpoint.term_side %}
     {% elif iface.connected_endpoint.term_side %}
         {# Connected to a CircuitTermination #}
         {# Connected to a CircuitTermination #}
@@ -86,22 +92,38 @@
             {% if peer_termination %}
             {% if peer_termination %}
                 {% if peer_termination.connected_endpoint %}
                 {% if peer_termination.connected_endpoint %}
                     <td>
                     <td>
-                        <a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">{{ peer_termination.connected_endpoint.device }}</a><br/>
-                        <small>via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a></small>
+                        <a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">
+                            {{ peer_termination.connected_endpoint.device }}
+                        </a><br/>
+                        <small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
+                            <a href="{{ iface.connected_endpoint.circuit.get_absolure_url }}">
+                                {{ iface.connected_endpoint.circuit.provider }}
+                                {{ iface.connected_endpoint.circuit }}
+                            </a>
+                        </small>
                     </td>
                     </td>
                     <td>
                     <td>
                         {{ peer_termination.connected_endpoint }}
                         {{ peer_termination.connected_endpoint }}
                     </td>
                     </td>
                 {% else %}
                 {% else %}
                     <td colspan="2">
                     <td colspan="2">
-                        <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>
-                        via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
+                        <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">
+                            {{ peer_termination.site }}
+                        </a>
+                        via <i class="fa fa-fw fa-globe" title="Circuit"></i>
+                        <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
+                            {{ iface.connected_endpoint.circuit.provider }}
+                            {{ iface.connected_endpoint.circuit }}
+                        </a>
                     </td>
                     </td>
                 {% endif %}
                 {% endif %}
             {% else %}
             {% else %}
                 <td colspan="2">
                 <td colspan="2">
                     <i class="fa fa-fw fa-globe" title="Circuit"></i>
                     <i class="fa fa-fw fa-globe" title="Circuit"></i>
-                    <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
+                    <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
+                        {{ iface.connected_endpoint.circuit.provider }}
+                        {{ iface.connected_endpoint.circuit }}
+                    </a>
                 </td>
                 </td>
             {% endif %}
             {% endif %}
         {% endwith %}
         {% endwith %}

+ 4 - 0
netbox/templates/dcim/interface.html

@@ -163,6 +163,10 @@
                             </tr>
                             </tr>
                         {% elif connected_circuittermination %}
                         {% elif connected_circuittermination %}
                             {% with ct=connected_circuittermination %}
                             {% with ct=connected_circuittermination %}
+                                <tr>
+                                    <td>Provider</td>
+                                    <td><a href="{{ ct.circuit.provider.get_absolute_url }}">{{ ct.circuit.provider }}</a></td>
+                                </tr>
                                 <tr>
                                 <tr>
                                     <td>Circuit</td>
                                     <td>Circuit</td>
                                     <td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>
                                     <td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>

+ 18 - 9
netbox/utilities/api.py

@@ -78,17 +78,26 @@ class ChoiceField(Field):
         return data
         return data
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
+
+        # Provide an explicit error message if the request is trying to write a dict
+        if type(data) is dict:
+            raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.')
+
+        # Check for string representations of boolean/integer values
         if hasattr(data, 'lower'):
         if hasattr(data, 'lower'):
-            # Hotwiring boolean values from string
             if data.lower() == 'true':
             if data.lower() == 'true':
-                return True
-            if data.lower() == 'false':
-                return False
-            # Check for string representation of an integer (e.g. "123")
-            try:
-                data = int(data)
-            except ValueError:
-                pass
+                data = True
+            elif data.lower() == 'false':
+                data = False
+            else:
+                try:
+                    data = int(data)
+                except ValueError:
+                    pass
+
+        if data not in self._choices:
+            raise ValidationError("{} is not a valid choice.".format(data))
+
         return data
         return data