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