Jeremy Stretch 7 سال پیش
والد
کامیت
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
 ## Bug Fixes
 
 
 * [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets
 * [#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
 * [#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
 * [#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
         # Check that NAPALM is installed
         try:
         try:
             import napalm
             import napalm
+            from napalm.base.exceptions import ModuleImportError
         except ImportError:
         except ImportError:
             raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
             raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
-        from napalm.base.exceptions import ModuleImportError
 
 
         # Validate the configured driver
         # Validate the configured driver
         try:
         try:
@@ -355,7 +355,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
             try:
             try:
                 response[method] = getattr(d, method)()
                 response[method] = getattr(d, method)()
             except NotImplementedError:
             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()
         d.close()
 
 
         return Response(response)
         return Response(response)

+ 1 - 1
netbox/dcim/views.py

@@ -900,7 +900,7 @@ class DeviceView(View):
         interfaces = device.vc_interfaces.select_related(
         interfaces = device.vc_interfaces.select_related(
             'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
             'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
         ).prefetch_related(
         ).prefetch_related(
-            'cable__termination_a', 'cable__termination_b', 'ip_addresses'
+            'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
         )
         )
 
 
         # Front ports
         # 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 dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 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 (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     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)
         self.fields['remove_tags'] = TagField(required=False)
 
 
 
 
+class TagFilterForm(BootstrapMixin, forms.Form):
+    model = Tag
+    q = forms.CharField(required=False, label='Search')
+
+
 #
 #
 # Config contexts
 # 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):
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,

+ 29 - 1
netbox/extras/tables.py

@@ -1,5 +1,6 @@
 import django_tables2 as tables
 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 utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from .models import ConfigContext, ObjectChange
 from .models import ConfigContext, ObjectChange
@@ -13,6 +14,14 @@ TAG_ACTIONS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+TAGGED_ITEM = """
+{% if value.get_absolute_url %}
+    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
+{% else %}
+    {{ value }}
+{% endif %}
+"""
+
 CONFIGCONTEXT_ACTIONS = """
 CONFIGCONTEXT_ACTIONS = """
 {% if perms.extras.change_configcontext %}
 {% 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>
     <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):
 class TagTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.LinkColumn(
+        viewname='extras:tag',
+        args=[Accessor('slug')]
+    )
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=TAG_ACTIONS,
         template_code=TAG_ACTIONS,
         attrs={'td': {'class': 'text-right'}},
         attrs={'td': {'class': 'text-right'}},
@@ -64,6 +77,21 @@ class TagTable(BaseTable):
         fields = ('pk', 'name', 'items', 'slug', 'actions')
         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):
 class ConfigContextTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn()
     name = tables.LinkColumn()

+ 2 - 0
netbox/extras/urls.py

@@ -7,6 +7,7 @@ urlpatterns = [
 
 
     # Tags
     # Tags
     url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
     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-]+)/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/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
@@ -14,6 +15,7 @@ urlpatterns = [
     # Config contexts
     # Config contexts
     url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
     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/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+)/$', 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+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
     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 import template
+from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 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.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 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.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 . 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 .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
 from .reports import get_report, get_reports
 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):
 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
     table = TagTable
     template_name = 'extras/tag_list.html'
     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):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'taggit.change_tag'
     permission_required = 'taggit.change_tag'
     model = Tag
     model = Tag
@@ -43,7 +83,11 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     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
     table = TagTable
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
@@ -83,6 +127,15 @@ class ConfigContextEditView(ConfigContextCreateView):
     permission_required = 'extras.change_configcontext'
     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):
 class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'extras.delete_configcontext'
     permission_required = 'extras.delete_configcontext'
     model = ConfigContext
     model = ConfigContext

+ 13 - 0
netbox/project-static/css/base.css

@@ -390,6 +390,19 @@ table.report th a {
     top: -51px;
     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 */
 /* AJAX loader */
 .loading {
 .loading {
     position: fixed;
     position: fixed;

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

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

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

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

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

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

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

@@ -116,7 +116,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if devicetype.comments %}
                 {% if devicetype.comments %}
                     {{ devicetype.comments|gfm }}
                     {{ devicetype.comments|gfm }}
                 {% else %}
                 {% 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 }}">
 <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 #}
     {# 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>
             <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>
             <a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
         </span>
         </span>
+        {% if iface.mac_address %}
+            <br/><small class="text-muted">{{ iface.mac_address }}</small>
+        {% endif %}
     </td>
     </td>
 
 
     {# LAG #}
     {# LAG #}
     <td>
     <td>
         {% if iface.lag %}
         {% 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 %}
         {% endif %}
+        {% for tag in iface.tags.all %}
+            {% tag tag %}
+        {% empty %}
+            {% if not iface.description %}&mdash;{% endif %}
+        {% endfor %}
     </td>
     </td>
 
 
-    {# Description #}
-    <td>{{ iface.description|default:"&mdash;" }}</td>
+    {# MTU #}
+    <td>{{ iface.mtu|default:"&mdash;" }}</td>
 
 
     {# 802.1Q mode #}
     {# 802.1Q mode #}
     <td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
     <td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
@@ -44,7 +60,13 @@
     {% if iface.is_lag %}
     {% if iface.is_lag %}
         <td colspan="2" class="text-muted">
         <td colspan="2" class="text-muted">
             LAG interface<br />
             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>
         </td>
     {% elif iface.is_virtual %}
     {% elif iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
@@ -138,7 +160,7 @@
             {% endif %}
             {% endif %}
 
 
             {# IP addresses table #}
             {# IP addresses table #}
-            <td colspan="8" style="padding: 0">
+            <td colspan="9" style="padding: 0">
                 <table class="table table-condensed interface-ips">
                 <table class="table table-condensed interface-ips">
                     <thead>
                     <thead>
                         <tr class="text-muted">
                         <tr class="text-muted">

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
netbox/virtualization/api/serializers.py

@@ -52,7 +52,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
     status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
     site = NestedSiteSerializer(read_only=True)
     site = NestedSiteSerializer(read_only=True)
-    cluster = NestedClusterSerializer(required=False, allow_null=True)
+    cluster = NestedClusterSerializer()
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(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.name, data['name'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
         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):
     def test_create_virtualmachine_bulk(self):
 
 
         data = [
         data = [