diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f82845b..02ca1bd7b 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/api/overview.md b/docs/api/overview.md index 85d972008..1115759d8 100644 --- a/docs/api/overview.md +++ b/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. diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 776b24156..f10221b0b 100644 --- a/netbox/circuits/models.py +++ b/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]) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2c0032cb4..7e0179171 100644 --- a/netbox/dcim/api/views.py +++ b/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( diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 47a202893..8e5e09a26 100644 --- a/netbox/dcim/constants.py +++ b/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)'], ] ], [ diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5dcf8a492..756ae9be2 100644 --- a/netbox/dcim/models.py +++ b/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 diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 2ac3bee06..67479262b 100644 --- a/netbox/dcim/signals.py +++ b/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() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index b38a60827..0c88852f0 100644 --- a/netbox/dcim/tables.py +++ b/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 %} """ diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e747bf71a..b4962dfd7 100644 --- a/netbox/extras/admin.py +++ b/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) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index f3301a6cc..d0a801b48 100644 --- a/netbox/extras/filters.py +++ b/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 ) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index c0d6732d1..3b7b26b66 100644 --- a/netbox/extras/forms.py +++ b/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' + ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index ca3f812a3..789e65b82 100644 --- a/netbox/ipam/models.py +++ b/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): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 284bcb4ae..026cbc980 100644 --- a/netbox/ipam/tables.py +++ b/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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6321ae98b..49c619962 100644 --- a/netbox/netbox/settings.py +++ b/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__))) diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 2bbc4695d..6b702c1dc 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -10,7 +10,7 @@ {% endfor %}
-

{% block title %}Circuit {{ obj.circuit }} - {{ form.term_side.value }} Side{% endblock %}

+

{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}

{% if form.non_field_errors %}
Errors
diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index e7b2891e7..48af97a0b 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -29,7 +29,7 @@ Circuit - {{ termination.circuit }} (Side {{ termination.term_side }}) + {{ termination.circuit }} ({{ termination }}) {% endif %} diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html index 1ed12c877..3c5a7c7fc 100644 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ b/netbox/templates/dcim/inc/cable_trace_end.html @@ -5,7 +5,7 @@ {% if end.device %} {{ end.device }} {% else %} - {{ end.circuit }} + {{ end.circuit.provider }} {% endif %}
@@ -21,7 +21,8 @@ {% endwith %} {% else %} {# Circuit termination #} - Side {{ end.term_side }} + {{ end.circuit }}
+ {{ end }} {% endif %}
diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index f976d7f85..3d8057208 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -75,10 +75,16 @@ {% elif iface.connected_endpoint.name %} {# Connected to an Interface #} - {{ iface.connected_endpoint.device }} + + {{ iface.connected_endpoint.device }} + - {{ iface.connected_endpoint }} + + + {{ iface.connected_endpoint }} + + {% elif iface.connected_endpoint.term_side %} {# Connected to a CircuitTermination #} @@ -86,22 +92,38 @@ {% if peer_termination %} {% if peer_termination.connected_endpoint %} - {{ peer_termination.connected_endpoint.device }}
- via {{ iface.connected_endpoint.circuit }} + + {{ peer_termination.connected_endpoint.device }} +
+ via + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + + {{ peer_termination.connected_endpoint }} {% else %} - {{ peer_termination.site }} - via {{ iface.connected_endpoint.circuit }} + + {{ peer_termination.site }} + + via + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + {% endif %} {% else %} - {{ iface.connected_endpoint.circuit }} + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + {% endif %} {% endwith %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 2ca17ac33..8625852e7 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -163,6 +163,10 @@ {% elif connected_circuittermination %} {% with ct=connected_circuittermination %} + + Provider + {{ ct.circuit.provider }} + Circuit {{ ct.circuit }} diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index c24fd1a16..530372fb9 100644 --- a/netbox/utilities/api.py +++ b/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