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)
## New Features

View File

@ -527,6 +527,10 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
queryset=VirtualChassis.objects.all(),
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(
method='_console_ports',
label='Has console ports',
@ -590,6 +594,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
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):
return queryset.exclude(consoleports__isnull=value)

View File

@ -601,12 +601,18 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
filter_for={
'group_id': 'site'
}
)
)
group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site'),
group_id = ChainedModelChoiceField(
label='Rack group',
null_label='-- None --',
queryset=RackGroup.objects.select_related('site'),
chains=(
('site', 'site'),
),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/",
null_option=True,
@ -951,10 +957,6 @@ class PowerPortTemplateCreateForm(ComponentForm):
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
class Meta:
model = PowerOutletTemplate
@ -965,6 +967,21 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
'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):
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 Meta:
@ -1739,6 +1750,13 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
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(
required=False,
label='Has console ports',
@ -3580,7 +3598,7 @@ class PowerFeedCSVForm(forms.ModelForm):
# Validate rack
if rack_name:
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:
raise forms.ValidationError(
"Rack {} not found in site {}, group {}".format(rack_name, site, rack_group)

View File

@ -2747,55 +2747,69 @@ class Cable(ChangeLoggedModel):
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
type_b = self.termination_b_type.model
# Validate that termination B exists
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
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
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
if (
type_a in ['frontport', 'rearport'] and
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")
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
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")
# Virtual interfaces cannot be connected
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
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")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Virtual interfaces cannot be connected
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
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
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):
model = PowerOutletTemplate
fields = ('pk', 'name')
fields = ('pk', 'name', 'power_port', 'feed_leg')
empty_text = "None"

View File

@ -87,7 +87,8 @@ class CustomLinkForm(forms.ModelForm):
model = CustomLink
exclude = []
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>.',
}

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' \
'{} <span class="caret"></span>\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'
@ -35,32 +36,40 @@ def custom_links(obj):
template_code = ''
group_names = OrderedDict()
# Organize custom links by group
for cl in custom_links:
# Organize custom links by group
if cl.group_name and cl.group_name in group_names:
group_names[cl.group_name].append(cl)
elif cl.group_name:
group_names[cl.group_name] = [cl]
# Add non-grouped links
for cl in custom_links:
if not cl.group_name:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, cl.text
)
# Add non-grouped links
else:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, text_rendered
)
# Add grouped links to template
for group, links in group_names.items():
template_code += GROUP_BUTTON.format(
links[0].button_class, group
)
links_rendered = []
for cl in links:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += GROUP_LINK.format(
cl.url, link_target, cl.text
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
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
rendered = Environment().from_string(source=template_code).render(**context)

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.6.0'
VERSION = '2.6.1'
# Hostname
HOSTNAME = platform.node()
@ -346,7 +346,7 @@ else:
REDIS_CACHE_CON_STRING = 'redis://'
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)

View File

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

View File

@ -15,6 +15,9 @@
<td>
{% if cp.cable %}
<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 %}
&mdash;
{% endif %}

View File

@ -23,6 +23,9 @@
<td>
{% if pp.cable %}
<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 %}
&mdash;
{% endif %}

View File

@ -9,7 +9,7 @@ from extras.models import Tag
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
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):
widget = forms.SelectMultiple
@ -17,7 +17,10 @@ def multivalue_field_factory(field_class):
def to_python(self, value):
if not value:
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())