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 = '
\n' \ '\n' \ - '
' GROUP_LINK = '
  • {}
  • \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 += '\n\n' # Render template rendered = Environment().from_string(source=template_code).render(**context) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8c9d288cc..2b6023f2a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 5f0bbdfc0..414b61bb6 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -359,7 +359,7 @@ {{ pp }} {{ utilization.outlet_count }} {{ utilization.allocated }}VA - {% if powerfeed %} + {% if powerfeed.available_power %} {{ powerfeed.available_power }}VA {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %} {% else %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index e75e09076..03c28c22a 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -15,6 +15,9 @@ {% if cp.cable %} {{ cp.cable }} + + + {% else %} — {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index e8cd77857..99e9e8991 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -23,6 +23,9 @@ {% if pp.cable %} {{ pp.cable }} + + + {% else %} — {% endif %} diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 614c09902..8ccdf2583 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -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())