Merge pull request #2688 from digitalocean/develop

Release v2.5.1
This commit is contained in:
Jeremy Stretch 2018-12-13 15:20:09 -05:00 committed by GitHub
commit 27a893a9a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 161 additions and 45 deletions

View File

@ -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

View File

@ -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.

View File

@ -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])

View File

@ -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(

View File

@ -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)'],
] ]
], ],
[ [

View File

@ -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( try:
rear_port=termination, peer_port = FrontPort.objects.get(
rear_port_position=position, rear_port=termination,
) rear_port_position=position,
return peer_port, 1 )
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) a_endpoint = a_path[-1][2]
return a_path[-1][2], b_path[-1][2], path_status b_endpoint = b_path[-1][2]
return a_endpoint, b_endpoint, path_status

View File

@ -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()

View File

@ -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 %}
""" """

View File

@ -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)

View File

@ -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
) )

View File

@ -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_after = forms.DateTimeField(
time_0 = 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'
)

View File

@ -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):

View File

@ -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

View File

@ -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__)))

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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/> <a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">
<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> {{ 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> <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">
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> {{ 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 %}

View File

@ -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>

View File

@ -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 data = True
if data.lower() == 'false': elif data.lower() == 'false':
return False data = False
# Check for string representation of an integer (e.g. "123") else:
try: try:
data = int(data) data = int(data)
except ValueError: except ValueError:
pass pass
if data not in self._choices:
raise ValidationError("{} is not a valid choice.".format(data))
return data return data