diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8f02322..c6504b30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,13 +80,21 @@ NetBox now supports modeling physical cables for console, power, and interface c --- -v2.4.8 (FUTURE) +v2.4.8 (2018-11-20) + +## Enhancements + +* [#2490](https://github.com/digitalocean/netbox/issues/2490) - Added bulk editing for config contexts +* [#2557](https://github.com/digitalocean/netbox/issues/2557) - Added object view for tags ## Bug Fixes * [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets * [#2558](https://github.com/digitalocean/netbox/issues/2558) - Filter on all tags when multiple are passed +* [#2565](https://github.com/digitalocean/netbox/issues/2565) - Improved rendering of Markdown tables * [#2575](https://github.com/digitalocean/netbox/issues/2575) - Correct model specified for rack roles table +* [#2588](https://github.com/digitalocean/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls +* [#2589](https://github.com/digitalocean/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment --- diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f6c62e86f..a80c7b8c2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -309,9 +309,9 @@ class DeviceViewSet(CustomFieldModelViewSet): # Check that NAPALM is installed try: import napalm + from napalm.base.exceptions import ModuleImportError except ImportError: raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") - from napalm.base.exceptions import ModuleImportError # Validate the configured driver try: @@ -355,7 +355,9 @@ class DeviceViewSet(CustomFieldModelViewSet): try: response[method] = getattr(d, method)() except NotImplementedError: - response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)} + response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} + except Exception as e: + response[method] = {'error': 'Method {} failed: {}'.format(method, e)} d.close() return Response(response) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e6fad5ee8..136e2c249 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -900,7 +900,7 @@ class DeviceView(View): interfaces = device.vc_interfaces.select_related( 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable' ).prefetch_related( - 'cable__termination_a', 'cable__termination_b', 'ip_addresses' + 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' ) # Front ports diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 3b900e8f5..e622bcfc0 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -11,8 +11,8 @@ from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, - JSONField, SlugField, + add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField, + FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, @@ -206,6 +206,11 @@ class AddRemoveTagsForm(forms.Form): self.fields['remove_tags'] = TagField(required=False) +class TagFilterForm(BootstrapMixin, forms.Form): + model = Tag + q = forms.CharField(required=False, label='Search') + + # # Config contexts # @@ -225,6 +230,28 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): ] +class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConfigContext.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False, + min_value=0 + ) + is_active = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + description = forms.CharField( + required=False, + max_length=100 + ) + + class Meta: + nullable_fields = ['description'] + + class ConfigContextFilterForm(BootstrapMixin, forms.Form): q = forms.CharField( required=False, diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 688f159db..5fab8910f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,5 +1,6 @@ import django_tables2 as tables -from taggit.models import Tag +from django_tables2.utils import Accessor +from taggit.models import Tag, TaggedItem from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ConfigContext, ObjectChange @@ -13,6 +14,14 @@ TAG_ACTIONS = """ {% endif %} """ +TAGGED_ITEM = """ +{% if value.get_absolute_url %} + {{ value }} +{% else %} + {{ value }} +{% endif %} +""" + CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -53,6 +62,10 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() + name = tables.LinkColumn( + viewname='extras:tag', + args=[Accessor('slug')] + ) actions = tables.TemplateColumn( template_code=TAG_ACTIONS, attrs={'td': {'class': 'text-right'}}, @@ -64,6 +77,21 @@ class TagTable(BaseTable): fields = ('pk', 'name', 'items', 'slug', 'actions') +class TaggedItemTable(BaseTable): + content_object = tables.TemplateColumn( + template_code=TAGGED_ITEM, + orderable=False, + verbose_name='Object' + ) + content_type = tables.Column( + verbose_name='Type' + ) + + class Meta(BaseTable.Meta): + model = TaggedItem + fields = ('content_object', 'content_type') + + class ConfigContextTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 2a8e09090..12a2fbf6b 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ # Tags url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), + url(r'^tags/(?P[\w-]+)/$', views.TagView.as_view(), name='tag'), url(r'^tags/(?P[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), @@ -14,6 +15,7 @@ urlpatterns = [ # Config contexts url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'), url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), url(r'^config-contexts/(?P\d+)/$', views.ConfigContextView.as_view(), name='configcontext'), url(r'^config-contexts/(?P\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'), url(r'^config-contexts/(?P\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 897b6e27b..e7087e511 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,4 +1,5 @@ from django import template +from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType @@ -7,15 +8,20 @@ from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe from django.views.generic import View -from taggit.models import Tag +from django_tables2 import RequestConfig +from taggit.models import Tag, TaggedItem from utilities.forms import ConfirmationForm -from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.paginator import EnhancedPaginator +from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters -from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm +from .forms import ( + ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, + TagFilterForm, TagForm, +) from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .reports import get_report, get_reports -from .tables import ConfigContextTable, ObjectChangeTable, TagTable +from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable # @@ -23,11 +29,45 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable # class TagListView(ObjectListView): - queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + queryset = Tag.objects.annotate( + items=Count('taggit_taggeditem_items') + ).order_by( + 'name' + ) + filter = filters.TagFilter + filter_form = TagFilterForm table = TagTable template_name = 'extras/tag_list.html' +class TagView(View): + + def get(self, request, slug): + + tag = get_object_or_404(Tag, slug=slug) + tagged_items = TaggedItem.objects.filter( + tag=tag + ).select_related( + 'content_type' + ).prefetch_related( + 'content_object' + ) + + # Generate a table of all items tagged with this Tag + items_table = TaggedItemTable(tagged_items) + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(items_table) + + return render(request, 'extras/tag.html', { + 'tag': tag, + 'items_count': tagged_items.count(), + 'items_table': items_table, + }) + + class TagEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'taggit.change_tag' model = Tag @@ -43,7 +83,11 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' - queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + queryset = Tag.objects.annotate( + items=Count('taggit_taggeditem_items') + ).order_by( + 'name' + ) table = TagTable default_return_url = 'extras:tag_list' @@ -83,6 +127,15 @@ class ConfigContextEditView(ConfigContextCreateView): permission_required = 'extras.change_configcontext' +class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'extras.change_configcontext' + queryset = ConfigContext.objects.all() + filter = filters.ConfigContextFilter + table = ConfigContextTable + form = ConfigContextBulkEditForm + default_return_url = 'extras:configcontext_list' + + class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'extras.delete_configcontext' model = ConfigContext diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 67b820413..155dd21b7 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -390,6 +390,19 @@ table.report th a { top: -51px; } +/* Rendered Markdown */ +.rendered-markdown table { + width: 100%; +} +.rendered-markdown th { + border-bottom: 2px solid #dddddd; + padding: 8px; +} +.rendered-markdown td { + border-top: 1px solid #dddddd; + padding: 8px; +} + /* AJAX loader */ .loading { position: fixed; diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 3e449ce22..edbab3ed4 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -113,7 +113,7 @@
Comments
-
+
{% if circuit.comments %} {{ circuit.comments|gfm }} {% else %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index ea936adcb..46fd8afc7 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -105,7 +105,7 @@
Comments
-
+
{% if provider.comments %} {{ provider.comments|gfm }} {% else %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index af4b9e209..16cc749d1 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -293,7 +293,7 @@
Comments
-
+
{% if device.comments %} {{ device.comments|gfm }} {% else %} @@ -508,6 +508,7 @@ Name LAG Description + MTU Mode Cable Connection diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index dba230fe2..1e9d30e51 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -116,7 +116,7 @@
Comments
-
+
{% if devicetype.comments %} {{ devicetype.comments|gfm }} {% else %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index f169d2e28..f976d7f85 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,3 +1,4 @@ +{% load helpers %} {# Checkbox #} @@ -13,17 +14,32 @@ {{ iface }} + {% if iface.mac_address %} +
{{ iface.mac_address }} + {% endif %} {# LAG #} {% if iface.lag %} - {{ iface.lag }} + {{ iface.lag }} {% endif %} - {# Description #} - {{ iface.description|default:"—" }} + {# Description/tags #} + + {% if iface.description %} + {{ iface.description }}
+ {% endif %} + {% for tag in iface.tags.all %} + {% tag tag %} + {% empty %} + {% if not iface.description %}—{% endif %} + {% endfor %} + + + {# MTU #} + {{ iface.mtu|default:"—" }} {# 802.1Q mode #} {{ iface.get_mode_display|default:"—" }} @@ -44,7 +60,13 @@ {% if iface.is_lag %} LAG interface
- {{ iface.member_interfaces.all|join:", "|default:"No members" }} + + {% for member in iface.member_interfaces.all %} + {{ member }}{% if not forloop.last %}, {% endif %} + {% empty %} + No members + {% endfor %} + {% elif iface.is_virtual %} Virtual interface @@ -138,7 +160,7 @@ {% endif %} {# IP addresses table #} - + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f78fb9503..84074b668 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -182,7 +182,7 @@
Comments
-
+
{% if rack.comments %} {{ rack.comments|gfm }} {% else %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1b35b0db9..0407b67f6 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -200,7 +200,7 @@
Comments
-
+
{% if site.comments %} {{ site.comments|gfm }} {% else %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html index c35ba76ff..16a1dc220 100644 --- a/netbox/templates/extras/configcontext_list.html +++ b/netbox/templates/extras/configcontext_list.html @@ -10,7 +10,7 @@

{% block title %}Config Contexts{% endblock %}

- {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
{% include 'inc/search_panel.html' %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html new file mode 100644 index 000000000..aceb0ca94 --- /dev/null +++ b/netbox/templates/extras/tag.html @@ -0,0 +1,69 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+ +
+
+
+ {% if perms.taggit.change_tag %} + + + Edit this tag + + {% endif %} +
+

{% block title %}Tag: {{ tag }}{% endblock %}

+{% endblock %} + +{% block content %} +
+
+
+
+ Tag +
+
+ + + + + + + + + + + + +
Name + {{ tag.name }} +
Slug + {{ tag.slug }} +
Tagged Items + {{ items_count }} +
+
+
+
+ {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html index 3136991a0..8178e5538 100644 --- a/netbox/templates/extras/tag_list.html +++ b/netbox/templates/extras/tag_list.html @@ -4,8 +4,11 @@ {% block content %}

{% block title %}Tags{% endblock %}

-
+
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
+
+ {% include 'inc/search_panel.html' %} +
{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 67f3c72fe..91d3ce986 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -81,7 +81,7 @@
Comments
-
+
{% if tenant.comments %} {{ tenant.comments|gfm }} {% else %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 69ed4e212..d3606e624 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -99,7 +99,7 @@
Comments
-
+
{% if cluster.comments %} {{ cluster.comments|gfm }} {% else %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 410ab32e5..1556c5af0 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -143,7 +143,7 @@
Comments
-
+
{% if virtualmachine.comments %} {{ virtualmachine.comments|gfm }} {% else %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1f2b957fc..1b06dab3b 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -52,7 +52,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer() role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4c03dddc5..ca66f1d2f 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -378,6 +378,18 @@ class VirtualMachineTest(APITestCase): self.assertEqual(virtualmachine4.name, data['name']) self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) + def test_create_virtualmachine_without_cluster(self): + + data = { + 'name': 'Test Virtual Machine 4', + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VirtualMachine.objects.count(), 3) + def test_create_virtualmachine_bulk(self): data = [