diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26b57caec..41c818be2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index f1c02e713..6312fd0d5 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -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)
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 1ae7b76f4..06e1b35b1 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -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)
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index e1d98a2d4..12270fc3e 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -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:
diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py
index aafb35a0f..3958e1326 100644
--- a/netbox/dcim/tables.py
+++ b/netbox/dcim/tables.py
@@ -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"
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index a29d0df09..d93b04037 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -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 {{ obj }}
.',
+ 'text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}
. Links '
+ 'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}
.',
}
diff --git a/netbox/extras/migrations/0023_fix_tag_sequences.py b/netbox/extras/migrations/0023_fix_tag_sequences.py
new file mode 100644
index 000000000..faef5aa96
--- /dev/null
+++ b/netbox/extras/migrations/0023_fix_tag_sequences.py
@@ -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)"),
+ ]
diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py
index 193c465a5..ce6cc482a 100644
--- a/netbox/extras/templatetags/custom_links.py
+++ b/netbox/extras/templatetags/custom_links.py
@@ -15,7 +15,8 @@ GROUP_BUTTON = '