Browse Source

Merge pull request #2688 from digitalocean/develop

Release v2.5.1
Jeremy Stretch 7 years ago
parent
commit
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)
 
 ## 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.
 
@@ -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
 
 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']
 
     def __str__(self):
-        return '{} {}'.format(self.provider, self.cid)
+        return self.cid
 
     def get_absolute_url(self):
         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
 
 
-class InterfaceConnectionViewSet(ModelViewSet):
+class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = Interface.objects.select_related(
         'device', '_connected_interface', '_connected_circuittermination'
     ).filter(

+ 2 - 0
netbox/dcim/constants.py

@@ -103,6 +103,7 @@ IFACE_FF_4GFC_SFP = 3040
 IFACE_FF_8GFC_SFP_PLUS = 3080
 IFACE_FF_16GFC_SFP_PLUS = 3160
 IFACE_FF_32GFC_SFP28 = 3320
+IFACE_FF_128GFC_QSFP28 = 3400
 # Serial
 IFACE_FF_T1 = 4000
 IFACE_FF_E1 = 4010
@@ -188,6 +189,7 @@ IFACE_FF_CHOICES = [
             [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
             [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
             [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(
                         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
             elif isinstance(termination, CircuitTermination) and follow_circuits:
@@ -2629,5 +2632,7 @@ class Cable(ChangeLoggedModel):
                     path_status = CONNECTION_STATUS_PLANNED
                     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()
 
     # 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.connection_status = None
         endpoint_a.save()

+ 1 - 1
netbox/dcim/tables.py

@@ -179,7 +179,7 @@ CABLE_TERMINATION_PARENT = """
 """
 
 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):
         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)

+ 4 - 4
netbox/extras/filters.py

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

+ 10 - 5
netbox/extras/forms.py

@@ -11,7 +11,7 @@ from taggit.models import Tag
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
+    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
     FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
 )
 from .constants import (
@@ -307,21 +307,20 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 # Change logging
 #
 
-class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     model = ObjectChange
     q = forms.CharField(
         required=False,
         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',
         required=False,
         widget=forms.TextInput(
             attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
         )
     )
-    time_1 = forms.DateTimeField(
+    time_before = forms.DateTimeField(
         label='Before',
         required=False,
         widget=forms.TextInput(
@@ -336,3 +335,9 @@ class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=User.objects.order_by('username'),
         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(
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
-        )
+        ).distinct()
 
 
 class Service(ChangeLoggedModel, CustomFieldModel):

+ 1 - 1
netbox/ipam/tables.py

@@ -430,7 +430,7 @@ class VLANDetailTable(VLANTable):
 
 class VLANMemberTable(BaseTable):
     parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
-    name = tables.Column(verbose_name='Interface')
+    name = tables.LinkColumn(verbose_name='Interface')
     untagged = tables.TemplateColumn(
         template_code=VLAN_MEMBER_UNTAGGED,
         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__)))
 

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

@@ -10,7 +10,7 @@
         {% endfor %}
         <div class="row">
             <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 %}
                     <div class="panel panel-danger">
                         <div class="panel-heading"><strong>Errors</strong></div>

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

@@ -29,7 +29,7 @@
         <tr>
             <td>Circuit</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>
         </tr>
     {% endif %}

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

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

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

@@ -75,10 +75,16 @@
     {% elif iface.connected_endpoint.name %}
         {# Connected to an Interface #}
         <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>
-            <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>
     {% elif iface.connected_endpoint.term_side %}
         {# Connected to a CircuitTermination #}
@@ -86,22 +92,38 @@
             {% if peer_termination %}
                 {% if peer_termination.connected_endpoint %}
                     <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>
                         {{ peer_termination.connected_endpoint }}
                     </td>
                 {% else %}
                     <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>
                 {% endif %}
             {% else %}
                 <td colspan="2">
                     <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>
             {% endif %}
         {% endwith %}

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

@@ -163,6 +163,10 @@
                             </tr>
                         {% elif 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>
                                     <td>Circuit</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
 
     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'):
-            # Hotwiring boolean values from string
             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