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

Merge branch 'develop' into develop-2.6

Jeremy Stretch 6 лет назад
Родитель
Сommit
4f9b666eee

+ 18 - 0
CHANGELOG.md

@@ -30,6 +30,24 @@ to now use "Extras | Tag."
 
 ---
 
+v2.5.10 (2019-04-08)
+
+## Enhancements
+
+* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates
+
+## Bug Fixes
+
+* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view
+* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces
+* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API
+* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update
+* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search
+* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint
+* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API
+
+---
+
 v2.5.9 (2019-04-01)
 
 ## Enhancements

+ 1 - 1
netbox/circuits/tables.py

@@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.circuit.change_circuittype %}
-    <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 3 - 1
netbox/dcim/api/views.py

@@ -426,7 +426,9 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
 
 
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
-    queryset = Interface.objects.select_related(
+    queryset = Interface.objects.filter(
+        device__isnull=False
+    ).select_related(
         'device', '_connected_interface', '_connected_circuittermination', 'cable'
     ).prefetch_related(
         'ip_addresses', 'tags'

+ 1 - 1
netbox/dcim/fields.py

@@ -31,7 +31,7 @@ class MACAddressField(models.Field):
         try:
             return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
         except AddrFormatError as e:
-            raise ValidationError(e)
+            raise ValidationError("Invalid MAC address format: {}".format(value))
 
     def db_type(self, connection):
         return 'macaddr'

+ 2 - 2
netbox/dcim/forms.py

@@ -2754,12 +2754,12 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
     status = forms.ChoiceField(
         choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
         required=False,
+        widget=StaticSelect2(),
         initial=''
     )
     label = forms.CharField(
         max_length=100,
-        required=False,
-        widget=StaticSelect2()
+        required=False
     )
     color = forms.CharField(
         max_length=6,

+ 8 - 8
netbox/dcim/tables.py

@@ -44,7 +44,7 @@ REGION_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_region %}
-    <a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:region_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -56,7 +56,7 @@ RACKGROUP_ACTIONS = """
     <i class="fa fa-eye"></i>
 </a>
 {% if perms.dcim.change_rackgroup %}
-    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
+    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning" title="Edit">
         <i class="glyphicon glyphicon-pencil"></i>
     </a>
 {% endif %}
@@ -67,7 +67,7 @@ RACKROLE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_rackrole %}
-    <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -88,7 +88,7 @@ RACKRESERVATION_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_rackreservation %}
-    <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -97,7 +97,7 @@ MANUFACTURER_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_manufacturer %}
-    <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -106,7 +106,7 @@ DEVICEROLE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_devicerole %}
-    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -131,7 +131,7 @@ PLATFORM_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_platform %}
-    <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:platform_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -168,7 +168,7 @@ VIRTUALCHASSIS_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_virtualchassis %}
-    <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 18 - 5
netbox/extras/api/serializers.py

@@ -17,7 +17,8 @@ from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantG
 from tenancy.models import Tenant, TenantGroup
 from users.api.nested_serializers import NestedUserSerializer
 from utilities.api import (
-    ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer,
+    ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
+    ValidatedModelSerializer,
 )
 from .nested_serializers import *
 
@@ -55,10 +56,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
 #
 
 class ExportTemplateSerializer(ValidatedModelSerializer):
+    template_language = ChoiceField(
+        choices=TEMPLATE_LANGUAGE_CHOICES,
+        default=TEMPLATE_LANGUAGE_JINJA2
+    )
 
     class Meta:
         model = ExportTemplate
-        fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
+        fields = [
+            'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
+            'file_extension',
+        ]
 
 
 #
@@ -238,9 +246,14 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
         """
         if obj.changed_object is None:
             return None
-        serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
-        if serializer is None:
+
+        try:
+            serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
+        except SerializerNotFound:
             return obj.object_repr
-        context = {'request': self.context['request']}
+        context = {
+            'request': self.context['request']
+        }
         data = serializer(obj.changed_object, context=context).data
+
         return data

+ 1 - 0
netbox/extras/api/views.py

@@ -25,6 +25,7 @@ from . import serializers
 
 class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
     fields = (
+        (ExportTemplate, ['template_language']),
         (Graph, ['type']),
         (ObjectChange, ['action']),
     )

+ 8 - 0
netbox/extras/constants.py

@@ -56,6 +56,14 @@ EXPORTTEMPLATE_MODELS = [
     'cluster', 'virtualmachine',                                                    # Virtualization
 ]
 
+# ExportTemplate language choices
+TEMPLATE_LANGUAGE_DJANGO = 10
+TEMPLATE_LANGUAGE_JINJA2 = 20
+TEMPLATE_LANGUAGE_CHOICES = (
+    (TEMPLATE_LANGUAGE_DJANGO, 'Django'),
+    (TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
+)
+
 # Topology map types
 TOPOLOGYMAP_TYPE_NETWORK = 1
 TOPOLOGYMAP_TYPE_CONSOLE = 2

+ 1 - 1
netbox/extras/filters.py

@@ -81,7 +81,7 @@ class ExportTemplateFilter(django_filters.FilterSet):
 
     class Meta:
         model = ExportTemplate
-        fields = ['content_type', 'name']
+        fields = ['content_type', 'name', 'template_language']
 
 
 class TagFilter(django_filters.FilterSet):

+ 2 - 3
netbox/extras/forms.py

@@ -4,14 +4,13 @@ from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
-from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.forms import TagField
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
-    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, CommentField
+    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField,
+    ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
 )
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,

+ 27 - 0
netbox/extras/migrations/0018_exporttemplate_add_jinja2.py

@@ -0,0 +1,27 @@
+# Generated by Django 2.1.7 on 2019-04-08 14:49
+
+from django.db import migrations, models
+
+
+def set_template_language(apps, schema_editor):
+    """
+    Set the language for all existing ExportTemplates to Django (Jinja2 is the default for new ExportTemplates).
+    """
+    ExportTemplate = apps.get_model('extras', 'ExportTemplate')
+    ExportTemplate.objects.update(template_language=10)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0017_exporttemplate_mime_type_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='exporttemplate',
+            name='template_language',
+            field=models.PositiveSmallIntegerField(default=20),
+        ),
+        migrations.RunPython(set_template_language),
+    ]

+ 31 - 6
netbox/extras/models.py

@@ -1,7 +1,6 @@
 from collections import OrderedDict
 from datetime import date
 
-import graphviz
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
@@ -12,6 +11,8 @@ from django.db.models import F, Q
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.urls import reverse
+import graphviz
+from jinja2 import Environment
 from taggit.models import TagBase, GenericTaggedItemBase
 
 from dcim.constants import CONNECTION_STATUS_CONNECTED
@@ -357,6 +358,10 @@ class ExportTemplate(models.Model):
         max_length=200,
         blank=True
     )
+    template_language = models.PositiveSmallIntegerField(
+        choices=TEMPLATE_LANGUAGE_CHOICES,
+        default=TEMPLATE_LANGUAGE_JINJA2
+    )
     template_code = models.TextField()
     mime_type = models.CharField(
         max_length=50,
@@ -376,17 +381,37 @@ class ExportTemplate(models.Model):
     def __str__(self):
         return '{}: {}'.format(self.content_type, self.name)
 
-    def render_to_response(self, queryset):
+    def render(self, queryset):
         """
-        Render the template to an HTTP response, delivered as a named file attachment
+        Render the contents of the template.
         """
-        template = Template(self.template_code)
-        mime_type = 'text/plain' if not self.mime_type else self.mime_type
-        output = template.render(Context({'queryset': queryset}))
+        context = {
+            'queryset': queryset
+        }
+
+        if self.template_language == TEMPLATE_LANGUAGE_DJANGO:
+            template = Template(self.template_code)
+            output = template.render(Context(context))
+
+        elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
+            template = Environment().from_string(source=self.template_code)
+            output = template.render(**context)
+
+        else:
+            return None
 
         # Replace CRLF-style line terminators
         output = output.replace('\r\n', '\n')
 
+        return output
+
+    def render_to_response(self, queryset):
+        """
+        Render the template to an HTTP response, delivered as a named file attachment
+        """
+        output = self.render(queryset)
+        mime_type = 'text/plain' if not self.mime_type else self.mime_type
+
         # Build the response
         response = HttpResponse(output, content_type=mime_type)
         filename = 'netbox_{}{}'.format(

+ 3 - 3
netbox/ipam/tables.py

@@ -30,7 +30,7 @@ RIR_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.ipam.change_rir %}
-    <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'ipam:rir_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -52,7 +52,7 @@ ROLE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.ipam.change_role %}
-    <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'ipam:role_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """
     {% endif %}
 {% endwith %}
 {% if perms.ipam.change_vlangroup %}
-    <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 4 - 4
netbox/netbox/api.py

@@ -147,18 +147,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
 # Miscellaneous
 #
 
-def get_view_name(view_cls, suffix=None):
+def get_view_name(view, suffix=None):
     """
     Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
     """
-    if hasattr(view_cls, 'queryset'):
+    if hasattr(view, 'queryset'):
         # Determine the model name from the queryset.
-        name = view_cls.queryset.model._meta.verbose_name
+        name = view.queryset.model._meta.verbose_name
         name = ' '.join([w[0].upper() + w[1:] for w in name.split()])  # Capitalize each word
 
     else:
         # Replicate DRF's built-in behavior.
-        name = view_cls.__name__
+        name = view.__class__.__name__
         name = formatting.remove_trailing_string(name, 'View')
         name = formatting.remove_trailing_string(name, 'ViewSet')
         name = formatting.camelcase_to_spaces(name)

+ 3 - 2
netbox/project-static/js/forms.js

@@ -156,11 +156,12 @@ $(document).ready(function() {
                 filter_for_elements.each(function(index, filter_for_element) {
                     var param_name = $(filter_for_element).attr(attr_name);
                     var is_nullable = $(filter_for_element).attr("nullable");
+                    var is_visible = $(filter_for_element).is(":visible");
                     var value = $(filter_for_element).val();
 
-                    if (param_name && value) {
+                    if (param_name && is_visible && value) {
                         parameters[param_name] = value;
-                    } else if (param_name && is_nullable) {
+                    } else if (param_name && is_visible && is_nullable) {
                         parameters[param_name] = "null";
                     }
                 });

+ 1 - 1
netbox/secrets/tables.py

@@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.secrets.change_secretrole %}
-    <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 1 - 1
netbox/tenancy/tables.py

@@ -8,7 +8,7 @@ TENANTGROUP_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.tenancy.change_tenantgroup %}
-    <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 2 - 2
netbox/virtualization/tables.py

@@ -11,7 +11,7 @@ CLUSTERTYPE_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.virtualization.change_clustertype %}
-    <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -20,7 +20,7 @@ CLUSTERGROUP_ACTIONS = """
     <i class="fa fa-history"></i>
 </a>
 {% if perms.virtualization.change_clustergroup %}
-    <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 

+ 1 - 1
netbox/virtualization/tests/test_api.py

@@ -645,7 +645,7 @@ class InterfaceTest(APITestCase):
 
     def test_delete_interface(self):
 
-        url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
+        url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

+ 1 - 0
requirements.txt

@@ -10,6 +10,7 @@ django-timezone-field==3.0
 djangorestframework==3.9.0
 drf-yasg[validation]==1.14.0
 graphviz==0.10.1
+Jinja2==2.10
 Markdown==2.6.11
 netaddr==0.7.19
 Pillow==5.3.0