Просмотр исходного кода

Merge pull request #3295 from digitalocean/develop

Release v2.6.1
Jeremy Stretch 6 лет назад
Родитель
Сommit
80c8c4c4b2

+ 20 - 0
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)
 v2.6.0 (2019-06-20)
 
 
 ## New Features
 ## New Features

+ 7 - 0
netbox/dcim/filters.py

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

+ 32 - 14
netbox/dcim/forms.py

@@ -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(
-        queryset=RackGroup.objects.select_related('site'),
+    group_id = ChainedModelChoiceField(
         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)

+ 63 - 49
netbox/dcim/models.py

@@ -2747,55 +2747,69 @@ class Cable(ChangeLoggedModel):
 
 
     def clean(self):
     def clean(self):
 
 
-        if self.termination_a and self.termination_b:
-
-            type_a = self.termination_a_type.model
-            type_b = self.termination_b_type.model
-
-            # 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 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))
-
-            # 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")
-
-            # 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 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)
+            })
+
+        # 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)
+            })
+
+        type_a = self.termination_a_type.model
+        type_b = self.termination_b_type.model
+
+        # 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 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))
+
+        # 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")
+
+        # 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
         # 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:

+ 1 - 1
netbox/dcim/tables.py

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

+ 2 - 1
netbox/extras/admin.py

@@ -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>.',
         }
         }
 
 

+ 14 - 0
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)"),
+    ]

+ 25 - 16
netbox/extras/templatetags/custom_links.py

@@ -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
-    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
     # 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 ''
-            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
     # Render template
     rendered = Environment().from_string(source=template_code).render(**context)
     rendered = Environment().from_string(source=template_code).render(**context)

+ 2 - 2
netbox/netbox/settings.py

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

+ 1 - 1
netbox/templates/dcim/device.html

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

+ 3 - 0
netbox/templates/dcim/inc/consoleport.html

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

+ 3 - 0
netbox/templates/dcim/inc/powerport.html

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

+ 5 - 2
netbox/utilities/filters.py

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