diff --git a/CHANGELOG.md b/CHANGELOG.md index d793e440d..dea8e81ed 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 1cddeffb2..60b6a7f7c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """ {% if perms.circuit.change_circuittype %} - + {% endif %} """ diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d83e1a42c..45df733b2 100644 --- a/netbox/dcim/api/views.py +++ b/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' diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 8d4bfba35..9624ce0a3 100644 --- a/netbox/dcim/fields.py +++ b/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' diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 96e566803..5a43d68e0 100644 --- a/netbox/dcim/forms.py +++ b/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, diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 436b9053d..dd1f4f5f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -44,7 +44,7 @@ REGION_ACTIONS = """ {% if perms.dcim.change_region %} - + {% endif %} """ @@ -56,7 +56,7 @@ RACKGROUP_ACTIONS = """ {% if perms.dcim.change_rackgroup %} - + {% endif %} @@ -67,7 +67,7 @@ RACKROLE_ACTIONS = """ {% if perms.dcim.change_rackrole %} - + {% endif %} """ @@ -88,7 +88,7 @@ RACKRESERVATION_ACTIONS = """ {% if perms.dcim.change_rackreservation %} - + {% endif %} """ @@ -97,7 +97,7 @@ MANUFACTURER_ACTIONS = """ {% if perms.dcim.change_manufacturer %} - + {% endif %} """ @@ -106,7 +106,7 @@ DEVICEROLE_ACTIONS = """ {% if perms.dcim.change_devicerole %} - + {% endif %} """ @@ -131,7 +131,7 @@ PLATFORM_ACTIONS = """ {% if perms.dcim.change_platform %} - + {% endif %} """ @@ -168,7 +168,7 @@ VIRTUALCHASSIS_ACTIONS = """ {% if perms.dcim.change_virtualchassis %} - + {% endif %} """ diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8d4fbb8a4..abf0d8cf5 100644 --- a/netbox/extras/api/serializers.py +++ b/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 diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 8e79c8834..c32aa92a9 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -25,6 +25,7 @@ from . import serializers class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): fields = ( + (ExportTemplate, ['template_language']), (Graph, ['type']), (ObjectChange, ['action']), ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 51fc398f7..13c15cbba 100644 --- a/netbox/extras/constants.py +++ b/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 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index eadbbae42..49e879fe4 100644 --- a/netbox/extras/filters.py +++ b/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): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b92955b1b..261822d28 100644 --- a/netbox/extras/forms.py +++ b/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, diff --git a/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py b/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py new file mode 100644 index 000000000..1177ac2fb --- /dev/null +++ b/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), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5f84cfacd..f45415c3c 100644 --- a/netbox/extras/models.py +++ b/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,16 +381,36 @@ class ExportTemplate(models.Model): def __str__(self): return '{}: {}'.format(self.content_type, self.name) + def render(self, queryset): + """ + Render the contents of the template. + """ + 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 """ - template = Template(self.template_code) + output = self.render(queryset) mime_type = 'text/plain' if not self.mime_type else self.mime_type - output = template.render(Context({'queryset': queryset})) - - # Replace CRLF-style line terminators - output = output.replace('\r\n', '\n') # Build the response response = HttpResponse(output, content_type=mime_type) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3d46452b2..fb48dda24 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -30,7 +30,7 @@ RIR_ACTIONS = """ {% if perms.ipam.change_rir %} - + {% endif %} """ @@ -52,7 +52,7 @@ ROLE_ACTIONS = """ {% if perms.ipam.change_role %} - + {% endif %} """ @@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """ {% endif %} {% endwith %} {% if perms.ipam.change_vlangroup %} - + {% endif %} """ diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 60c493be7..d8592f341 100644 --- a/netbox/netbox/api.py +++ b/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) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 3c1ed5a2a..96d59ace5 100644 --- a/netbox/project-static/js/forms.js +++ b/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"; } }); diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index a547ef4f8..1f937f54b 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """ {% if perms.secrets.change_secretrole %} - + {% endif %} """ diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2938a4aae..884bdc3df 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -8,7 +8,7 @@ TENANTGROUP_ACTIONS = """ {% if perms.tenancy.change_tenantgroup %} - + {% endif %} """ diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index f6cb06abd..6034dd8dc 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -11,7 +11,7 @@ CLUSTERTYPE_ACTIONS = """ {% if perms.virtualization.change_clustertype %} - + {% endif %} """ @@ -20,7 +20,7 @@ CLUSTERGROUP_ACTIONS = """ {% if perms.virtualization.change_clustergroup %} - + {% endif %} """ diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index d618ebe30..eef304c2f 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/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) diff --git a/requirements.txt b/requirements.txt index 49e7cf39e..f65328ecd 100644 --- a/requirements.txt +++ b/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