Merge pull request #3295 from digitalocean/develop

Release v2.6.1
This commit is contained in:
Jeremy Stretch 2019-06-25 09:41:29 -04:00 committed by GitHub
commit 80c8c4c4b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 172 additions and 80 deletions

View File

@ -1,3 +1,23 @@
v2.6.1 (2019-06-25)
## Enhancements
* [#3154](https://github.com/digitalocean/netbox/issues/3154) - Add `virtual_chassis_member` device filter
* [#3277](https://github.com/digitalocean/netbox/issues/3277) - Add cable trace buttons for console and power ports
* [#3281](https://github.com/digitalocean/netbox/issues/3281) - Hide custom links which render as empty text
## Bug Fixes
* [#3229](https://github.com/digitalocean/netbox/issues/3229) - Limit rack group selection by parent site on racks list
* [#3269](https://github.com/digitalocean/netbox/issues/3269) - Raise validation error when specifying non-existent cable terminations
* [#3275](https://github.com/digitalocean/netbox/issues/3275) - Fix error when adding power outlets to a device type
* [#3279](https://github.com/digitalocean/netbox/issues/3279) - Reset the PostgreSQL sequence for Tag and TaggedItem IDs
* [#3283](https://github.com/digitalocean/netbox/issues/3283) - Fix rack group assignment on PowerFeed CSV import
* [#3290](https://github.com/digitalocean/netbox/issues/3290) - Fix server error when viewing cascaded PDUs
* [#3292](https://github.com/digitalocean/netbox/issues/3292) - Ignore empty URL query parameters
---
v2.6.0 (2019-06-20) v2.6.0 (2019-06-20)
## New Features ## New Features

View File

@ -527,6 +527,10 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)', label='Virtual chassis (ID)',
) )
virtual_chassis_member = django_filters.BooleanFilter(
method='_virtual_chassis_member',
label='Is a virtual chassis member'
)
console_ports = django_filters.BooleanFilter( console_ports = django_filters.BooleanFilter(
method='_console_ports', method='_console_ports',
label='Has console ports', label='Has console ports',
@ -590,6 +594,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
Q(primary_ip6__isnull=False) Q(primary_ip6__isnull=False)
) )
def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value)
def _console_ports(self, queryset, name, value): def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleports__isnull=value) return queryset.exclude(consoleports__isnull=value)

View File

@ -601,12 +601,18 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
filter_for={
'group_id': 'site'
}
) )
) )
group_id = FilterChoiceField( group_id = ChainedModelChoiceField(
queryset=RackGroup.objects.select_related('site'),
label='Rack group', label='Rack group',
null_label='-- None --', queryset=RackGroup.objects.select_related('site'),
chains=(
('site', 'site'),
),
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/", api_url="/api/dcim/rack-groups/",
null_option=True, null_option=True,
@ -951,10 +957,6 @@ class PowerPortTemplateCreateForm(ComponentForm):
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
@ -965,6 +967,21 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
} }
class PowerOutletTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(POWERFEED_LEG_CHOICES),
required=False,
widget=StaticSelect2()
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -975,12 +992,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
) )
class PowerOutletTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
@ -1739,6 +1750,13 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
virtual_chassis_member = forms.NullBooleanField(
required=False,
label='Virtual chassis member',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField( console_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console ports', label='Has console ports',
@ -3580,7 +3598,7 @@ class PowerFeedCSVForm(forms.ModelForm):
# Validate rack # Validate rack
if rack_name: if rack_name:
try: try:
self.instance.rack = Rack.objects.get(site=site, rack_group=rack_group, name=rack_name) self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
except Rack.DoesNotExist: except Rack.DoesNotExist:
raise forms.ValidationError( raise forms.ValidationError(
"Rack {} not found in site {}, group {}".format(rack_name, site, rack_group) "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group)

View File

@ -2747,55 +2747,69 @@ class Cable(ChangeLoggedModel):
def clean(self): def clean(self):
if self.termination_a and self.termination_b: # Validate that termination A exists
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
type_a = self.termination_a_type.model # Validate that termination B exists
type_b = self.termination_b_type.model try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# Check that termination types are compatible type_a = self.termination_a_type.model
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): type_b = self.termination_b_type.model
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
# A termination point cannot be connected to itself # Check that termination types are compatible
if self.termination_a == self.termination_b: if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
# A front port cannot be connected to its corresponding rear port # A termination point cannot be connected to itself
if ( if self.termination_a == self.termination_b:
type_a in ['frontport', 'rearport'] and raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object # A front port cannot be connected to its corresponding rear port
if self.termination_a.cable not in (None, self): if (
raise ValidationError("{} already has a cable attached (#{})".format( type_a in ['frontport', 'rearport'] and
self.termination_a, self.termination_a.cable_id type_b in ['frontport', 'rearport'] and
)) (
if self.termination_b.cable not in (None, self): getattr(self.termination_a, 'rear_port', None) == self.termination_b or
raise ValidationError("{} already has a cable attached (#{})".format( getattr(self.termination_b, 'rear_port', None) == self.termination_a
self.termination_b, self.termination_b.cable_id )
)) ):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Virtual interfaces cannot be connected # Check for an existing Cable connected to either termination object
endpoint_a, endpoint_b, _ = self.get_path_endpoints() if self.termination_a.cable not in (None, self):
if ( raise ValidationError("{} already has a cable attached (#{})".format(
( self.termination_a, self.termination_a.cable_id
isinstance(endpoint_a, Interface) and ))
endpoint_a.type == IFACE_TYPE_VIRTUAL if self.termination_b.cable not in (None, self):
) or raise ValidationError("{} already has a cable attached (#{})".format(
( self.termination_b, self.termination_b.cable_id
isinstance(endpoint_b, Interface) and ))
endpoint_b.type == IFACE_TYPE_VIRTUAL
) # Virtual interfaces cannot be connected
): endpoint_a, endpoint_b, _ = self.get_path_endpoints()
raise ValidationError("Cannot connect to a virtual interface") if (
(
isinstance(endpoint_a, Interface) and
endpoint_a.type == IFACE_TYPE_VIRTUAL
) or
(
isinstance(endpoint_b, Interface) and
endpoint_b.type == IFACE_TYPE_VIRTUAL
)
):
raise ValidationError("Cannot connect to a virtual interface")
# Validate length and length_unit # Validate length and length_unit
if self.length is not None and self.length_unit is None: if self.length is not None and self.length_unit is None:

View File

@ -433,7 +433,7 @@ class PowerOutletTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerOutletTemplate model = PowerOutletTemplate
fields = ('pk', 'name') fields = ('pk', 'name', 'power_port', 'feed_leg')
empty_text = "None" empty_text = "None"

View File

@ -87,7 +87,8 @@ class CustomLinkForm(forms.ModelForm):
model = CustomLink model = CustomLink
exclude = [] exclude = []
help_texts = { help_texts = {
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>.', 'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.', 'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
} }

View File

@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0022_custom_links'),
]
operations = [
# Update the last_value for tag Tag and TaggedItem ID sequences
migrations.RunSQL("SELECT setval('extras_tag_id_seq', (SELECT id FROM extras_tag ORDER BY id DESC LIMIT 1) + 1)"),
migrations.RunSQL("SELECT setval('extras_taggeditem_id_seq', (SELECT id FROM extras_taggeditem ORDER BY id DESC LIMIT 1) + 1)"),
]

View File

@ -15,7 +15,8 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
'<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \ '<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \
'{} <span class="caret"></span>\n' \ '{} <span class="caret"></span>\n' \
'</button>\n' \ '</button>\n' \
'<ul class="dropdown-menu pull-right">\n' '<ul class="dropdown-menu pull-right">\n' \
'{}</ul></div>'
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n' GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
@ -35,32 +36,40 @@ def custom_links(obj):
template_code = '' template_code = ''
group_names = OrderedDict() group_names = OrderedDict()
# Organize custom links by group
for cl in custom_links: for cl in custom_links:
# Organize custom links by group
if cl.group_name and cl.group_name in group_names: if cl.group_name and cl.group_name in group_names:
group_names[cl.group_name].append(cl) group_names[cl.group_name].append(cl)
elif cl.group_name: elif cl.group_name:
group_names[cl.group_name] = [cl] group_names[cl.group_name] = [cl]
# Add non-grouped links # Add non-grouped links
for cl in custom_links: else:
if not cl.group_name: text_rendered = Environment().from_string(source=cl.text).render(**context)
link_target = ' target="_blank"' if cl.new_window else '' if text_rendered:
template_code += LINK_BUTTON.format( link_target = ' target="_blank"' if cl.new_window else ''
cl.url, link_target, cl.button_class, cl.text template_code += LINK_BUTTON.format(
) cl.url, link_target, cl.button_class, text_rendered
)
# Add grouped links to template # Add grouped links to template
for group, links in group_names.items(): for group, links in group_names.items():
template_code += GROUP_BUTTON.format(
links[0].button_class, group links_rendered = []
)
for cl in links: for cl in links:
link_target = ' target="_blank"' if cl.new_window else '' text_rendered = Environment().from_string(source=cl.text).render(**context)
template_code += GROUP_LINK.format( if text_rendered:
cl.url, link_target, cl.text link_target = ' target="_blank"' if cl.new_window else ''
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
)
if links_rendered:
template_code += GROUP_BUTTON.format(
links[0].button_class, group, ''.join(links_rendered)
) )
template_code += '</ul>\n</div>\n'
# Render template # Render template
rendered = Environment().from_string(source=template_code).render(**context) rendered = Environment().from_string(source=template_code).render(**context)

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.6.0' VERSION = '2.6.1'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -346,7 +346,7 @@ else:
REDIS_CACHE_CON_STRING = 'redis://' REDIS_CACHE_CON_STRING = 'redis://'
if REDIS_PASSWORD: if REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD) REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE) REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE)

View File

@ -359,7 +359,7 @@
<td>{{ pp }}</td> <td>{{ pp }}</td>
<td>{{ utilization.outlet_count }}</td> <td>{{ utilization.outlet_count }}</td>
<td>{{ utilization.allocated }}VA</td> <td>{{ utilization.allocated }}VA</td>
{% if powerfeed %} {% if powerfeed.available_power %}
<td>{{ powerfeed.available_power }}VA</td> <td>{{ powerfeed.available_power }}VA</td>
<td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td> <td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td>
{% else %} {% else %}

View File

@ -15,6 +15,9 @@
<td> <td>
{% if cp.cable %} {% if cp.cable %}
<a href="{{ cp.cable.get_absolute_url }}">{{ cp.cable }}</a> <a href="{{ cp.cable.get_absolute_url }}">{{ cp.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=cp.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
{% else %} {% else %}
&mdash; &mdash;
{% endif %} {% endif %}

View File

@ -23,6 +23,9 @@
<td> <td>
{% if pp.cable %} {% if pp.cable %}
<a href="{{ pp.cable.get_absolute_url }}">{{ pp.cable }}</a> <a href="{{ pp.cable.get_absolute_url }}">{{ pp.cable }}</a>
<a href="{% url 'dcim:powerport_trace' pk=pp.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
{% else %} {% else %}
&mdash; &mdash;
{% endif %} {% endif %}

View File

@ -9,7 +9,7 @@ from extras.models import Tag
def multivalue_field_factory(field_class): def multivalue_field_factory(field_class):
""" """
Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple
filter values while maintaining the field's built-in vlaidation. Example: GET /api/dcim/devices/?name=foo&name=bar filter values while maintaining the field's built-in validation. Example: GET /api/dcim/devices/?name=foo&name=bar
""" """
class NewField(field_class): class NewField(field_class):
widget = forms.SelectMultiple widget = forms.SelectMultiple
@ -17,7 +17,10 @@ def multivalue_field_factory(field_class):
def to_python(self, value): def to_python(self, value):
if not value: if not value:
return [] return []
return [super(field_class, self).to_python(v) for v in value] return [
# Only append non-empty values (this avoids e.g. trying to cast '' as an integer)
super(field_class, self).to_python(v) for v in value if v
]
return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())