Jeremy Stretch 7 лет назад
Родитель
Сommit
874acab90f

+ 9 - 1
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
 
 ---
 

+ 4 - 2
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)

+ 1 - 1
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

+ 29 - 2
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,

+ 29 - 1
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 %}
+    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
+{% else %}
+    {{ value }}
+{% endif %}
+"""
+
 CONFIGCONTEXT_ACTIONS = """
 {% if perms.extras.change_configcontext %}
     <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -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()

+ 2 - 0
netbox/extras/urls.py

@@ -7,6 +7,7 @@ urlpatterns = [
 
     # Tags
     url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
+    url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
     url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
     url(r'^tags/(?P<slug>[\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<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
     url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),

+ 59 - 6
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

+ 13 - 0
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;

+ 1 - 1
netbox/templates/circuits/circuit.html

@@ -113,7 +113,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if circuit.comments %}
                     {{ circuit.comments|gfm }}
                 {% else %}

+ 1 - 1
netbox/templates/circuits/provider.html

@@ -105,7 +105,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if provider.comments %}
                     {{ provider.comments|gfm }}
                 {% else %}

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

@@ -293,7 +293,7 @@
                 <div class="panel-heading">
                     <strong>Comments</strong>
                 </div>
-                <div class="panel-body">
+                <div class="panel-body rendered-markdown">
                     {% if device.comments %}
                         {{ device.comments|gfm }}
                     {% else %}
@@ -508,6 +508,7 @@
                                 <th>Name</th>
                                 <th>LAG</th>
                                 <th>Description</th>
+                                <th>MTU</th>
                                 <th>Mode</th>
                                 <th>Cable</th>
                                 <th colspan="2">Connection</th>

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

@@ -116,7 +116,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if devicetype.comments %}
                     {{ devicetype.comments|gfm }}
                 {% else %}

+ 27 - 5
netbox/templates/dcim/inc/interface.html

@@ -1,3 +1,4 @@
+{% load helpers %}
 <tr class="interface{% if not iface.enabled %} danger{% elif iface.cable.status %} success{% elif iface.cable %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
 
     {# Checkbox #}
@@ -13,17 +14,32 @@
             <i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}align-justify{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
             <a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
         </span>
+        {% if iface.mac_address %}
+            <br/><small class="text-muted">{{ iface.mac_address }}</small>
+        {% endif %}
     </td>
 
     {# LAG #}
     <td>
         {% if iface.lag %}
-            <a href="#iface_{{ iface.lag }}" class="label label-default" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
+            <a href="#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
+        {% endif %}
+    </td>
+
+    {# Description/tags #}
+    <td>
+        {% if iface.description %}
+            {{ iface.description }}<br/>
         {% endif %}
+        {% for tag in iface.tags.all %}
+            {% tag tag %}
+        {% empty %}
+            {% if not iface.description %}&mdash;{% endif %}
+        {% endfor %}
     </td>
 
-    {# Description #}
-    <td>{{ iface.description|default:"&mdash;" }}</td>
+    {# MTU #}
+    <td>{{ iface.mtu|default:"&mdash;" }}</td>
 
     {# 802.1Q mode #}
     <td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
@@ -44,7 +60,13 @@
     {% if iface.is_lag %}
         <td colspan="2" class="text-muted">
             LAG interface<br />
-            <small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
+            <small class="text-muted">
+                {% for member in iface.member_interfaces.all %}
+                    <a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
+                {% empty %}
+                    No members
+                {% endfor %}
+            </small>
         </td>
     {% elif iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
@@ -138,7 +160,7 @@
             {% endif %}
 
             {# IP addresses table #}
-            <td colspan="8" style="padding: 0">
+            <td colspan="9" style="padding: 0">
                 <table class="table table-condensed interface-ips">
                     <thead>
                         <tr class="text-muted">

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

@@ -182,7 +182,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if rack.comments %}
                     {{ rack.comments|gfm }}
                 {% else %}

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

@@ -200,7 +200,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if site.comments %}
                     {{ site.comments|gfm }}
                 {% else %}

+ 1 - 1
netbox/templates/extras/configcontext_list.html

@@ -10,7 +10,7 @@
     <h1>{% block title %}Config Contexts{% endblock %}</h1>
     <div class="row">
         <div class="col-md-9">
-            {% 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' %}
         </div>
         <div class="col-md-3">
             {% include 'inc/search_panel.html' %}

+ 69 - 0
netbox/templates/extras/tag.html

@@ -0,0 +1,69 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:tag_list' %}">Tags</a></li>
+                <li>{{ tag }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'extras:tag_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.taggit.change_tag %}
+            <a href="{% url 'extras:tag_edit' slug=tag.slug %}?return_url={% url 'extras:tag' slug=tag.slug %}" class="btn btn-warning">
+                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+                Edit this tag
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Tag</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Name</td>
+                        <td>
+                            {{ tag.name }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Slug</td>
+                        <td>
+                            {{ tag.slug }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tagged Items</td>
+                        <td>
+                            {{ items_count }}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-6">
+            {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
+        </div>
+    </div>
+{% endblock %}

+ 4 - 1
netbox/templates/extras/tag_list.html

@@ -4,8 +4,11 @@
 {% block content %}
 <h1>{% block title %}Tags{% endblock %}</h1>
 <div class="row">
-	<div class="col-md-12">
+	<div class="col-md-9">
         {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
     </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/tenancy/tenant.html

@@ -81,7 +81,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if tenant.comments %}
                     {{ tenant.comments|gfm }}
                 {% else %}

+ 1 - 1
netbox/templates/virtualization/cluster.html

@@ -99,7 +99,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if cluster.comments %}
                     {{ cluster.comments|gfm }}
                 {% else %}

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -143,7 +143,7 @@
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if virtualmachine.comments %}
                     {{ virtualmachine.comments|gfm }}
                 {% else %}

+ 1 - 1
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)

+ 12 - 0
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 = [