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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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