Merge v2.4.8

This commit is contained in:
Jeremy Stretch 2018-11-20 12:28:24 -05:00
commit 874acab90f
23 changed files with 270 additions and 30 deletions

View File

@ -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
--- ---

View File

@ -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)

View File

@ -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

View File

@ -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, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
JSONField, SlugField, 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,

View File

@ -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()

View File

@ -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'),

View File

@ -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

View File

@ -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;

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %} {% endif %}
</td> </td>
{# Description #} {# Description/tags #}
<td>{{ iface.description|default:"&mdash;" }}</td> <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>
{# 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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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' %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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 = [