@@ -49,19 +63,19 @@
Name
- {{ vm }}
+ {{ virtualmachine }}
Status
- {{ vm.get_status_display }}
+ {{ virtualmachine.get_status_display }}
Role
- {% if vm.role %}
- {{ vm.role }}
+ {% if virtualmachine.role %}
+ {{ virtualmachine.role }}
{% else %}
None
{% endif %}
@@ -70,8 +84,8 @@
Platform
- {% if vm.platform %}
- {{ vm.platform }}
+ {% if virtualmachine.platform %}
+ {{ virtualmachine.platform }}
{% else %}
None
{% endif %}
@@ -80,12 +94,12 @@
Tenant
- {% if vm.tenant %}
- {% if vm.tenant.group %}
- {{ vm.tenant.group }}
+ {% if virtualmachine.tenant %}
+ {% if virtualmachine.tenant.group %}
+ {{ virtualmachine.tenant.group }}
{% endif %}
- {{ vm.tenant }}
+ {{ virtualmachine.tenant }}
{% else %}
None
{% endif %}
@@ -94,12 +108,12 @@
Primary IPv4
- {% if vm.primary_ip4 %}
- {{ vm.primary_ip4.address.ip }}
- {% if vm.primary_ip4.nat_inside %}
- (NAT for {{ vm.primary_ip4.nat_inside.address.ip }})
- {% elif vm.primary_ip4.nat_outside %}
- (NAT: {{ vm.primary_ip4.nat_outside.address.ip }})
+ {% if virtualmachine.primary_ip4 %}
+ {{ virtualmachine.primary_ip4.address.ip }}
+ {% if virtualmachine.primary_ip4.nat_inside %}
+ (NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }})
+ {% elif virtualmachine.primary_ip4.nat_outside %}
+ (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }})
{% endif %}
{% else %}
N/A
@@ -109,12 +123,12 @@
Primary IPv6
- {% if vm.primary_ip6 %}
- {{ vm.primary_ip6.address.ip }}
- {% if vm.primary_ip6.nat_inside %}
- (NAT for {{ vm.primary_ip6.nat_inside.address.ip }})
- {% elif vm.primary_ip6.nat_outside %}
- (NAT: {{ vm.primary_ip6.nat_outside.address.ip }})
+ {% if virtualmachine.primary_ip6 %}
+ {{ virtualmachine.primary_ip6.address.ip }}
+ {% if virtualmachine.primary_ip6.nat_inside %}
+ (NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }})
+ {% elif virtualmachine.primary_ip6.nat_outside %}
+ (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }})
{% endif %}
{% else %}
N/A
@@ -123,14 +137,15 @@
- {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
+ {% include 'inc/custom_fields_panel.html' with obj=virtualmachine %}
+ {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %}
Comments
- {% if vm.comments %}
- {{ vm.comments|gfm }}
+ {% if virtualmachine.comments %}
+ {{ virtualmachine.comments|gfm }}
{% else %}
None
{% endif %}
@@ -146,16 +161,16 @@
Cluster
- {% if vm.cluster.group %}
- {{ vm.cluster.group }}
+ {% if virtualmachine.cluster.group %}
+ {{ virtualmachine.cluster.group }}
{% endif %}
- {{ vm.cluster }}
+ {{ virtualmachine.cluster }}
Cluster Type
- {{ vm.cluster.type }}
+ {{ virtualmachine.cluster.type }}
@@ -167,8 +182,8 @@
Virtual CPUs
- {% if vm.vcpus %}
- {{ vm.vcpus }}
+ {% if virtualmachine.vcpus %}
+ {{ virtualmachine.vcpus }}
{% else %}
N/A
{% endif %}
@@ -177,8 +192,8 @@
Memory
- {% if vm.memory %}
- {{ vm.memory }} MB
+ {% if virtualmachine.memory %}
+ {{ virtualmachine.memory }} MB
{% else %}
N/A
{% endif %}
@@ -187,8 +202,8 @@
Disk Space
- {% if vm.disk %}
- {{ vm.disk }} GB
+ {% if virtualmachine.disk %}
+ {{ virtualmachine.disk }} GB
{% else %}
N/A
{% endif %}
@@ -213,7 +228,7 @@
{% endif %}
{% if perms.ipam.add_service %}
@@ -226,7 +241,7 @@
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
{% for iface in interfaces %}
- {% include 'dcim/inc/interface.html' with device=vm %}
+ {% include 'dcim/inc/interface.html' with device=virtualmachine %}
{% empty %}
- — No interfaces defined —
+ — No interfaces defined —
{% endfor %}
@@ -265,21 +279,21 @@
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
{% endif %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
Comments
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html
index 30ed76dae..bf2961fd8 100644
--- a/netbox/templates/virtualization/virtualmachine_list.html
+++ b/netbox/templates/virtualization/virtualmachine_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 454e41c52..592e35a6e 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -1,10 +1,11 @@
from __future__ import unicode_literals
from rest_framework import serializers
+from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup
-from utilities.api import ValidatedModelSerializer
+from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
#
@@ -18,7 +19,7 @@ class TenantGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedTenantGroupSerializer(serializers.ModelSerializer):
+class NestedTenantGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta:
@@ -30,24 +31,21 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer):
# Tenants
#
-class TenantSerializer(CustomFieldModelSerializer):
- group = NestedTenantGroupSerializer()
+class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
+ group = NestedTenantGroupSerializer(required=False)
+ tags = TagListSerializerField(required=False)
class Meta:
model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
+ fields = [
+ 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
+ ]
-class NestedTenantSerializer(serializers.ModelSerializer):
+class NestedTenantSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta:
model = Tenant
fields = ['id', 'url', 'name', 'slug']
-
-
-class WritableTenantSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 26f9bc71e..1ebd95500 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet):
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related('group')
serializer_class = serializers.TenantSerializer
- write_serializer_class = serializers.WritableTenantSerializer
filter_class = filters.TenantFilter
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py
index 330ab7f56..7eccff5d3 100644
--- a/netbox/tenancy/filters.py
+++ b/netbox/tenancy/filters.py
@@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Tenant
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
index 4ea6c57ba..b90934923 100644
--- a/netbox/tenancy/forms.py
+++ b/netbox/tenancy/forms.py
@@ -2,8 +2,9 @@ from __future__ import unicode_literals
from django import forms
from django.db.models import Count
+from taggit.forms import TagField
-from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import (
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField,
)
@@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm):
class TenantForm(BootstrapMixin, CustomFieldForm):
slug = SlugField()
comments = CommentField()
+ tags = TagField(required=False)
class Meta:
model = Tenant
- fields = ['name', 'slug', 'group', 'description', 'comments']
+ fields = ['name', 'slug', 'group', 'description', 'comments', 'tags']
class TenantCSVForm(forms.ModelForm):
@@ -67,7 +69,7 @@ class TenantCSVForm(forms.ModelForm):
}
-class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py
new file mode 100644
index 000000000..d4258f4dc
--- /dev/null
+++ b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.14 on 2018-07-31 02:12
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ replaces = [('tenancy', '0002_tenant_group_optional'), ('tenancy', '0003_unicode_literals')]
+
+ dependencies = [
+ ('tenancy', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tenant',
+ name='group',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup'),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='description',
+ field=models.CharField(blank=True, help_text='Long-form name (optional)', max_length=100),
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/0004_tags.py b/netbox/tenancy/migrations/0004_tags.py
new file mode 100644
index 000000000..5cb9398b5
--- /dev/null
+++ b/netbox/tenancy/migrations/0004_tags.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('tenancy', '0003_unicode_literals'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/0005_change_logging.py b/netbox/tenancy/migrations/0005_change_logging.py
new file mode 100644
index 000000000..7712e9d02
--- /dev/null
+++ b/netbox/tenancy/migrations/0005_change_logging.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0004_tags'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 1fea2ceaf..5a22143d3 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -4,18 +4,24 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
-from extras.models import CustomFieldModel, CustomFieldValue
-from utilities.models import CreatedUpdatedModel
+from extras.models import CustomFieldModel
+from utilities.models import ChangeLoggedModel
@python_2_unicode_compatible
-class TenantGroup(models.Model):
+class TenantGroup(ChangeLoggedModel):
"""
An arbitrary collection of Tenants.
"""
- name = models.CharField(max_length=50, unique=True)
- slug = models.SlugField(unique=True)
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
csv_headers = ['name', 'slug']
@@ -36,17 +42,40 @@ class TenantGroup(models.Model):
@python_2_unicode_compatible
-class Tenant(CreatedUpdatedModel, CustomFieldModel):
+class Tenant(ChangeLoggedModel, CustomFieldModel):
"""
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department.
"""
- name = models.CharField(max_length=30, unique=True)
- slug = models.SlugField(unique=True)
- group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL)
- description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
- comments = models.TextField(blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ name = models.CharField(
+ max_length=30,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ group = models.ForeignKey(
+ to='tenancy.TenantGroup',
+ on_delete=models.SET_NULL,
+ related_name='tenants',
+ blank=True,
+ null=True
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True,
+ help_text='Long-form name (optional)'
+ )
+ comments = models.TextField(
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+
+ tags = TaggableManager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index b3c67e9e2..2e763591a 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Tenant, TenantGroup
TENANTGROUP_ACTIONS = """
+
+
+
{% if perms.tenancy.change_tenantgroup %}
{% endif %}
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index f1238eddb..95e1a6de3 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -1,22 +1,17 @@
from __future__ import unicode_literals
-from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
-from rest_framework.test import APITestCase
from tenancy.models import Tenant, TenantGroup
-from users.models import Token
-from utilities.tests import HttpStatusMixin
+from utilities.testing import APITestCase
-class TenantGroupTest(HttpStatusMixin, APITestCase):
+class TenantGroupTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(TenantGroupTest, self).setUp()
self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
@@ -103,13 +98,11 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(TenantGroup.objects.count(), 2)
-class TenantTest(HttpStatusMixin, APITestCase):
+class TenantTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(TenantTest, self).setUp()
self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index 668b194f0..2da03b7f5 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
+from extras.views import ObjectChangeLogView
from . import views
+from .models import Tenant, TenantGroup
app_name = 'tenancy'
urlpatterns = [
@@ -13,6 +15,7 @@ urlpatterns = [
url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
url(r'^tenant-groups/(?P
[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
+ url(r'^tenant-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
# Tenants
url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
@@ -23,5 +26,6 @@ urlpatterns = [
url(r'^tenants/(?P[\w-]+)/$', views.TenantView.as_view(), name='tenant'),
url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
+ url(r'^tenants/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
]
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 9020a8c19..fdb453665 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -3,7 +3,6 @@ from __future__ import unicode_literals
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render
-from django.urls import reverse
from django.views.generic import View
from circuits.models import Circuit
@@ -31,9 +30,7 @@ class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.add_tenantgroup'
model = TenantGroup
model_form = forms.TenantGroupForm
-
- def get_return_url(self, request, obj):
- return reverse('tenancy:tenantgroup_list')
+ default_return_url = 'tenancy:tenantgroup_list'
class TenantGroupEditView(TenantGroupCreateView):
@@ -49,7 +46,6 @@ class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenantgroup'
- cls = TenantGroup
queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list'
@@ -118,7 +114,6 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'tenancy.change_tenant'
- cls = Tenant
queryset = Tenant.objects.select_related('group')
filter = filters.TenantFilter
table = tables.TenantTable
@@ -128,7 +123,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenant'
- cls = Tenant
queryset = Tenant.objects.select_related('group')
filter = filters.TenantFilter
table = tables.TenantTable
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 80f79516c..861bdade9 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -1,10 +1,11 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
-from rest_framework import serializers
+
+from utilities.api import WritableNestedSerializer
-class NestedUserSerializer(serializers.ModelSerializer):
+class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = User
diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py
new file mode 100644
index 000000000..54a6078a0
--- /dev/null
+++ b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.14 on 2018-08-01 17:43
+from __future__ import unicode_literals
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ replaces = [('users', '0001_api_tokens'), ('users', '0002_unicode_literals')]
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Token',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('expires', models.DateTimeField(blank=True, null=True)),
+ ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])),
+ ('write_enabled', models.BooleanField(default=True, help_text='Permit create/update/delete operations using this key')),
+ ('description', models.CharField(blank=True, max_length=100)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'default_permissions': [],
+ },
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 02f5bc0a0..b3698d925 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -16,12 +16,31 @@ class Token(models.Model):
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
It also supports setting an expiration time and toggling write ability.
"""
- user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE)
- created = models.DateTimeField(auto_now_add=True)
- expires = models.DateTimeField(blank=True, null=True)
- key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)])
- write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key")
- description = models.CharField(max_length=100, blank=True)
+ user = models.ForeignKey(
+ to=User,
+ on_delete=models.CASCADE,
+ related_name='tokens'
+ )
+ created = models.DateTimeField(
+ auto_now_add=True
+ )
+ expires = models.DateTimeField(
+ blank=True,
+ null=True
+ )
+ key = models.CharField(
+ max_length=40,
+ unique=True,
+ validators=[MinLengthValidator(40)]
+ )
+ write_enabled = models.BooleanField(
+ default=True,
+ help_text='Permit create/update/delete operations using this key'
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
class Meta:
default_permissions = []
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 5c78dacc4..0ce207d6e 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -5,16 +5,17 @@ import pytz
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ManyToManyField
from django.http import Http404
-from rest_framework import mixins
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
+from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
-from rest_framework.viewsets import GenericViewSet, ViewSet
+from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
-WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
+from .utils import dynamic_import
class ServiceUnavailable(APIException):
@@ -22,6 +23,20 @@ class ServiceUnavailable(APIException):
default_detail = "Service temporarily unavailable, please try again later."
+def get_serializer_for_model(model, prefix=''):
+ """
+ Dynamically resolve and return the appropriate serializer for a model.
+ """
+ app_name, model_name = model._meta.label.split('.')
+ serializer_name = '{}.api.serializers.{}{}Serializer'.format(
+ app_name, prefix, model_name
+ )
+ try:
+ return dynamic_import(serializer_name)
+ except AttributeError:
+ return None
+
+
#
# Authentication
#
@@ -33,41 +48,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
def has_permission(self, request, view):
if not settings.LOGIN_REQUIRED:
return True
- return request.user.is_authenticated()
+ return request.user.is_authenticated
#
-# Serializers
+# Fields
#
-class ValidatedModelSerializer(ModelSerializer):
- """
- Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
- """
- def validate(self, data):
-
- # Remove custom field data (if any) prior to model validation
- attrs = data.copy()
- attrs.pop('custom_fields', None)
-
- # Run clean() on an instance of the model
- if self.instance is None:
- model = self.Meta.model
- # Ignore ManyToManyFields for new instances (a PK is needed for validation)
- for field in model._meta.get_fields():
- if isinstance(field, ManyToManyField) and field.name in attrs:
- attrs.pop(field.name)
- instance = self.Meta.model(**attrs)
- else:
- instance = self.instance
- for k, v in attrs.items():
- setattr(instance, k, v)
- instance.clean()
-
- return data
-
-
-class ChoiceFieldSerializer(Field):
+class ChoiceField(Field):
"""
Represent a ChoiceField as {'value': , 'label': }.
"""
@@ -80,16 +68,16 @@ class ChoiceFieldSerializer(Field):
self._choices[k2] = v2
else:
self._choices[k] = v
- super(ChoiceFieldSerializer, self).__init__(**kwargs)
+ super(ChoiceField, self).__init__(**kwargs)
def to_representation(self, obj):
return {'value': obj, 'label': self._choices[obj]}
def to_internal_value(self, data):
- return self._choices.get(data)
+ return data
-class ContentTypeFieldSerializer(Field):
+class ContentTypeField(Field):
"""
Represent a ContentType as '.'
"""
@@ -108,7 +96,6 @@ class TimeZoneField(Field):
"""
Represent a pytz time zone.
"""
-
def to_representation(self, obj):
return obj.zone if obj else None
@@ -121,31 +108,81 @@ class TimeZoneField(Field):
raise ValidationError('Invalid time zone "{}"'.format(data))
+class SerializedPKRelatedField(PrimaryKeyRelatedField):
+ """
+ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
+ objects in a ManyToManyField while still allowing a set of primary keys to be written.
+ """
+ def __init__(self, serializer, **kwargs):
+ self.serializer = serializer
+ self.pk_field = kwargs.pop('pk_field', None)
+ super(SerializedPKRelatedField, self).__init__(**kwargs)
+
+ def to_representation(self, value):
+ return self.serializer(value, context={'request': self.context['request']}).data
+
+
+#
+# Serializers
+#
+
+# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant
+# way to enforce model validation on the serializer.
+class ValidatedModelSerializer(ModelSerializer):
+ """
+ Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
+ """
+ def validate(self, data):
+
+ # Remove custom fields data and tags (if any) prior to model validation
+ attrs = data.copy()
+ attrs.pop('custom_fields', None)
+ attrs.pop('tags', None)
+
+ # Skip ManyToManyFields
+ for field in self.Meta.model._meta.get_fields():
+ if isinstance(field, ManyToManyField):
+ attrs.pop(field.name, None)
+
+ # Run clean() on an instance of the model
+ if self.instance is None:
+ instance = self.Meta.model(**attrs)
+ else:
+ instance = self.instance
+ for k, v in attrs.items():
+ setattr(instance, k, v)
+ instance.clean()
+
+ return data
+
+
+class WritableNestedSerializer(ModelSerializer):
+ """
+ Returns a nested representation of an object on read, but accepts only a primary key on write.
+ """
+ def to_internal_value(self, data):
+ if data is None:
+ return None
+ try:
+ return self.Meta.model.objects.get(pk=data)
+ except ObjectDoesNotExist:
+ raise ValidationError("Invalid ID")
+
+
#
# Viewsets
#
-class ModelViewSet(mixins.CreateModelMixin,
- mixins.RetrieveModelMixin,
- mixins.UpdateModelMixin,
- mixins.DestroyModelMixin,
- mixins.ListModelMixin,
- GenericViewSet):
+class ModelViewSet(_ModelViewSet):
"""
- Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
- 1. Use an alternate serializer (if provided) for write operations
- 2. Accept either a single object or a list of objects to create
+ Accept either a single object or a list of objects to create.
"""
- def get_serializer_class(self):
- # Check for a different serializer to use for write operations
- if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
- return self.write_serializer_class
- return self.serializer_class
-
def get_serializer(self, *args, **kwargs):
+
# If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True
+
return super(ModelViewSet, self).get_serializer(*args, **kwargs)
diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py
index 58c8641ec..dab35e982 100644
--- a/netbox/utilities/context_processors.py
+++ b/netbox/utilities/context_processors.py
@@ -4,6 +4,9 @@ from django.conf import settings as django_settings
def settings(request):
+ """
+ Expose Django settings in the template context. Example: {{ settings.DEBUG }}
+ """
return {
'settings': django_settings,
}
diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py
index b97506b85..ca6e08fc1 100644
--- a/netbox/utilities/custom_inspectors.py
+++ b/netbox/utilities/custom_inspectors.py
@@ -3,7 +3,7 @@ from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector,
from rest_framework.fields import ChoiceField
from extras.api.customfields import CustomFieldsSerializer
-from utilities.api import ChoiceFieldSerializer
+from utilities.api import ChoiceField
class CustomChoiceFieldInspector(FieldInspector):
@@ -12,7 +12,7 @@ class CustomChoiceFieldInspector(FieldInspector):
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
- if isinstance(field, ChoiceFieldSerializer):
+ if isinstance(field, ChoiceField):
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
choices = list(field._choices.keys())
diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py
index d0a0c2180..34f59fe16 100644
--- a/netbox/utilities/fields.py
+++ b/netbox/utilities/fields.py
@@ -5,7 +5,12 @@ from django.db import models
from .forms import ColorSelect
-validate_color = RegexValidator('^[0-9a-f]{6}$', 'Enter a valid hexadecimal RGB color code.', 'invalid')
+
+ColorValidator = RegexValidator(
+ regex='^[0-9a-f]{6}$',
+ message='Enter a valid hexadecimal RGB color code.',
+ code='invalid'
+)
class NullableCharField(models.CharField):
@@ -21,7 +26,7 @@ class NullableCharField(models.CharField):
class ColorField(models.CharField):
- default_validators = [validate_color]
+ default_validators = [ColorValidator]
description = "A hexadecimal RGB color code"
def __init__(self, *args, **kwargs):
diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py
index 3e403e676..90cdcd9fc 100644
--- a/netbox/utilities/filters.py
+++ b/netbox/utilities/filters.py
@@ -19,6 +19,9 @@ class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
class NullableCharFieldFilter(django_filters.CharFilter):
+ """
+ Allow matching on null field values by passing a special string used to signify NULL.
+ """
null_value = 'NULL'
def filter(self, qs, value):
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 1cea0b0da..1e6e3c0c4 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -6,6 +6,7 @@ import re
from django import forms
from django.conf import settings
+from django.contrib.postgres.forms import JSONField as _JSONField
from django.db.models import Count
from django.urls import reverse_lazy
from mptt.forms import TreeNodeMultipleChoiceField
@@ -153,6 +154,9 @@ def add_blank_choice(choices):
#
class SmallTextarea(forms.Textarea):
+ """
+ Subclass used for rendering a smaller textarea element.
+ """
pass
@@ -168,6 +172,9 @@ class ColorSelect(forms.Select):
class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
+ """
+ A Select widget for NullBooleanFields
+ """
def __init__(self, *args, **kwargs):
super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs)
@@ -326,7 +333,7 @@ class CSVChoiceField(forms.ChoiceField):
"""
def __init__(self, choices, *args, **kwargs):
- super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
+ super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs)
self.choices = [(label, label) for value, label in choices]
self.choice_values = {label: value for value, label in choices}
@@ -447,7 +454,9 @@ class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
class SlugField(forms.SlugField):
-
+ """
+ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
+ """
def __init__(self, slug_source='name', *args, **kwargs):
label = kwargs.pop('label', "Slug")
help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
@@ -536,16 +545,36 @@ class LaxURLField(forms.URLField):
default_validators = [EnhancedURLValidator()]
+class JSONField(_JSONField):
+ """
+ Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
+ """
+ def __init__(self, *args, **kwargs):
+ super(JSONField, self).__init__(*args, **kwargs)
+ if not self.help_text:
+ self.help_text = 'Enter context data in JSON format.'
+ self.widget.attrs['placeholder'] = ''
+
+ def prepare_value(self, value):
+ if value is None:
+ return ''
+ return super(JSONField, self).prepare_value(value)
+
+
#
# Forms
#
class BootstrapMixin(forms.BaseForm):
-
+ """
+ Add the base Bootstrap CSS classes to form elements.
+ """
def __init__(self, *args, **kwargs):
super(BootstrapMixin, self).__init__(*args, **kwargs)
- exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect]
+ exempt_widgets = [
+ forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect
+ ]
for field_name, field in self.fields.items():
if field.widget.__class__ not in exempt_widgets:
@@ -615,14 +644,15 @@ class ComponentForm(BootstrapMixin, forms.Form):
class BulkEditForm(forms.Form):
-
+ """
+ Base form for editing multiple objects in bulk
+ """
def __init__(self, model, parent_obj=None, *args, **kwargs):
super(BulkEditForm, self).__init__(*args, **kwargs)
self.model = model
self.parent_obj = parent_obj
+ self.nullable_fields = []
# Copy any nullable fields defined in Meta
if hasattr(self.Meta, 'nullable_fields'):
- self.nullable_fields = [field for field in self.Meta.nullable_fields]
- else:
- self.nullable_fields = []
+ self.nullable_fields = self.Meta.nullable_fields
diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py
index 33b4356d4..b112f4fae 100644
--- a/netbox/utilities/managers.py
+++ b/netbox/utilities/managers.py
@@ -4,29 +4,35 @@ from django.db.models import Manager
class NaturalOrderByManager(Manager):
+ """
+ Order objects naturally by a designated field. Leading and/or trailing digits of values within this field will be
+ cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before "Foo10", even though
+ the digit 1 is normally ordered before the digit 2.
+ """
+ natural_order_field = None
- def natural_order_by(self, *fields):
- """
- Attempt to order records naturally by segmenting a field into three parts:
+ def get_queryset(self):
- 1. Leading integer (if any)
- 2. Middle portion
- 3. Trailing integer (if any)
+ queryset = super(NaturalOrderByManager, self).get_queryset()
- :param fields: The fields on which to order the queryset. The last field in the list will be ordered naturally.
- """
db_table = self.model._meta.db_table
- primary_field = fields[-1]
+ db_field = self.natural_order_field
- id1 = '_{}_{}1'.format(db_table, primary_field)
- id2 = '_{}_{}2'.format(db_table, primary_field)
- id3 = '_{}_{}3'.format(db_table, primary_field)
-
- queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={
- id1: r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, primary_field),
- id2: r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field),
- id3: r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, primary_field),
+ # Append the three subfields derived from the designated natural ordering field
+ queryset = queryset.extra(select={
+ '_nat1': r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field),
+ '_nat2': r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field),
+ '_nat3': r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field),
})
- ordering = fields[0:-1] + (id1, id2, id3)
+
+ # Replace any instance of the designated natural ordering field with its three subfields
+ ordering = []
+ for field in self.model._meta.ordering:
+ if field == self.natural_order_field:
+ ordering.append('_nat1')
+ ordering.append('_nat2')
+ ordering.append('_nat3')
+ else:
+ ordering.append(field)
return queryset.order_by(*ordering)
diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py
index 70d018023..dafafde24 100644
--- a/netbox/utilities/middleware.py
+++ b/netbox/utilities/middleware.py
@@ -21,7 +21,7 @@ class LoginRequiredMiddleware(object):
self.get_response = get_response
def __call__(self, request):
- if LOGIN_REQUIRED and not request.user.is_authenticated():
+ if LOGIN_REQUIRED and not request.user.is_authenticated:
# Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
# performs its own authentication.
api_path = reverse('api-root')
diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py
index c6768c4c1..4b04c03e1 100644
--- a/netbox/utilities/models.py
+++ b/netbox/utilities/models.py
@@ -2,10 +2,38 @@ from __future__ import unicode_literals
from django.db import models
+from extras.models import ObjectChange
+from utilities.utils import serialize_object
-class CreatedUpdatedModel(models.Model):
- created = models.DateField(auto_now_add=True)
- last_updated = models.DateTimeField(auto_now=True)
+
+class ChangeLoggedModel(models.Model):
+ """
+ An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
+ null to facilitate adding these fields to existing instances via a database migration.
+ """
+ created = models.DateField(
+ auto_now_add=True,
+ blank=True,
+ null=True
+ )
+ last_updated = models.DateTimeField(
+ auto_now=True,
+ blank=True,
+ null=True
+ )
class Meta:
abstract = True
+
+ def log_change(self, user, request_id, action):
+ """
+ Create a new ObjectChange representing a change made to this object. This will typically be called automatically
+ by extras.middleware.ChangeLoggingMiddleware.
+ """
+ ObjectChange(
+ user=user,
+ request_id=request_id,
+ changed_object=self,
+ action=action,
+ object_data=serialize_object(self)
+ ).save()
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index 8694d986b..e531b5e32 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -22,7 +22,9 @@ class BaseTable(tables.Table):
class ToggleColumn(tables.CheckBoxColumn):
-
+ """
+ Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
+ """
def __init__(self, *args, **kwargs):
default = kwargs.pop('default', '')
visible = kwargs.pop('visible', False)
@@ -31,3 +33,18 @@ class ToggleColumn(tables.CheckBoxColumn):
@property
def header(self):
return mark_safe(' ')
+
+
+class BooleanColumn(tables.Column):
+ """
+ Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
+ character.
+ """
+ def render(self, value):
+ if value is True:
+ rendered = ' '
+ elif value is False:
+ rendered = ' '
+ else:
+ rendered = '— '
+ return mark_safe(rendered)
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 7d79a5f2a..39959a668 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -1,7 +1,7 @@
from __future__ import unicode_literals
import datetime
-import pytz
+import json
from django import template
from django.utils.safestring import mark_safe
@@ -47,6 +47,14 @@ def gfm(value):
return mark_safe(html)
+@register.filter()
+def render_json(value):
+ """
+ Render a dictionary as formatted JSON.
+ """
+ return json.dumps(value, indent=4, sort_keys=True)
+
+
@register.filter()
def model_name(obj):
"""
@@ -160,3 +168,14 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
'warning_threshold': warning_threshold,
'danger_threshold': danger_threshold,
}
+
+
+@register.inclusion_tag('utilities/templatetags/tag.html')
+def tag(tag, url_name=None):
+ """
+ Display a tag, optionally linked to a filtered list of objects.
+ """
+ return {
+ 'tag': tag,
+ 'url_name': url_name,
+ }
diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py
new file mode 100644
index 000000000..dcc564dfa
--- /dev/null
+++ b/netbox/utilities/testing.py
@@ -0,0 +1,26 @@
+from __future__ import unicode_literals
+
+from django.contrib.auth.models import User
+from rest_framework.test import APITestCase as _APITestCase
+
+from users.models import Token
+
+
+class APITestCase(_APITestCase):
+
+ def setUp(self):
+ """
+ Create a superuser and token for API calls.
+ """
+ self.user = User.objects.create(username='testuser', is_superuser=True)
+ self.token = Token.objects.create(user=self.user)
+ self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
+
+ def assertHttpStatus(self, response, expected_status):
+ """
+ Provide more detail in the event of an unexpected HTTP response.
+ """
+ err_message = "Expected HTTP status {}; received {}: {}"
+ self.assertEqual(response.status_code, expected_status, err_message.format(
+ expected_status, response.status_code, response.data
+ ))
diff --git a/netbox/utilities/tests.py b/netbox/utilities/tests.py
deleted file mode 100644
index d40202842..000000000
--- a/netbox/utilities/tests.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from __future__ import unicode_literals
-
-
-class HttpStatusMixin(object):
- """
- Custom mixin to provide more detail in the event of an unexpected HTTP response.
- """
-
- def assertHttpStatus(self, response, expected_status):
- err_message = "Expected HTTP status {}; received {}: {}"
- self.assertEqual(response.status_code, expected_status, err_message.format(
- expected_status, response.status_code, response.data
- ))
diff --git a/netbox/utilities/tests/__init__.py b/netbox/utilities/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py
new file mode 100644
index 000000000..0bafaefde
--- /dev/null
+++ b/netbox/utilities/tests/test_managers.py
@@ -0,0 +1,192 @@
+from __future__ import unicode_literals
+
+from django.test import TestCase
+
+from dcim.models import Site
+
+
+class NaturalOrderByManagerTest(TestCase):
+ """
+ Ensure consistent natural ordering given myriad sample data. We use dcim.Site as our guinea pig because it's simple.
+ """
+
+ def setUp(self):
+ return
+
+ def evaluate_ordering(self, names):
+
+ # Create the Sites
+ Site.objects.bulk_create(
+ Site(name=name, slug=name.lower()) for name in names
+ )
+
+ # Validate forward ordering
+ self.assertEqual(
+ names,
+ list(Site.objects.values_list('name', flat=True))
+ )
+
+ # Validate reverse ordering
+ self.assertEqual(
+ list(reversed(names)),
+ list(Site.objects.reverse().values_list('name', flat=True))
+ )
+
+ def test_leading_digits(self):
+
+ self.evaluate_ordering([
+ '1Alpha',
+ '1Bravo',
+ '1Charlie',
+ '9Alpha',
+ '9Bravo',
+ '9Charlie',
+ '10Alpha',
+ '10Bravo',
+ '10Charlie',
+ '99Alpha',
+ '99Bravo',
+ '99Charlie',
+ '100Alpha',
+ '100Bravo',
+ '100Charlie',
+ '999Alpha',
+ '999Bravo',
+ '999Charlie',
+ ])
+
+ def test_trailing_digits(self):
+
+ self.evaluate_ordering([
+ 'Alpha1',
+ 'Alpha9',
+ 'Alpha10',
+ 'Alpha99',
+ 'Alpha100',
+ 'Alpha999',
+ 'Bravo1',
+ 'Bravo9',
+ 'Bravo10',
+ 'Bravo99',
+ 'Bravo100',
+ 'Bravo999',
+ 'Charlie1',
+ 'Charlie9',
+ 'Charlie10',
+ 'Charlie99',
+ 'Charlie100',
+ 'Charlie999',
+ ])
+
+ def test_leading_and_trailing_digits(self):
+
+ self.evaluate_ordering([
+ '1Alpha1',
+ '1Alpha9',
+ '1Alpha10',
+ '1Alpha99',
+ '1Alpha100',
+ '1Alpha999',
+ '1Bravo1',
+ '1Bravo9',
+ '1Bravo10',
+ '1Bravo99',
+ '1Bravo100',
+ '1Bravo999',
+ '1Charlie1',
+ '1Charlie9',
+ '1Charlie10',
+ '1Charlie99',
+ '1Charlie100',
+ '1Charlie999',
+ '9Alpha1',
+ '9Alpha9',
+ '9Alpha10',
+ '9Alpha99',
+ '9Alpha100',
+ '9Alpha999',
+ '9Bravo1',
+ '9Bravo9',
+ '9Bravo10',
+ '9Bravo99',
+ '9Bravo100',
+ '9Bravo999',
+ '9Charlie1',
+ '9Charlie9',
+ '9Charlie10',
+ '9Charlie99',
+ '9Charlie100',
+ '9Charlie999',
+ '10Alpha1',
+ '10Alpha9',
+ '10Alpha10',
+ '10Alpha99',
+ '10Alpha100',
+ '10Alpha999',
+ '10Bravo1',
+ '10Bravo9',
+ '10Bravo10',
+ '10Bravo99',
+ '10Bravo100',
+ '10Bravo999',
+ '10Charlie1',
+ '10Charlie9',
+ '10Charlie10',
+ '10Charlie99',
+ '10Charlie100',
+ '10Charlie999',
+ '99Alpha1',
+ '99Alpha9',
+ '99Alpha10',
+ '99Alpha99',
+ '99Alpha100',
+ '99Alpha999',
+ '99Bravo1',
+ '99Bravo9',
+ '99Bravo10',
+ '99Bravo99',
+ '99Bravo100',
+ '99Bravo999',
+ '99Charlie1',
+ '99Charlie9',
+ '99Charlie10',
+ '99Charlie99',
+ '99Charlie100',
+ '99Charlie999',
+ '100Alpha1',
+ '100Alpha9',
+ '100Alpha10',
+ '100Alpha99',
+ '100Alpha100',
+ '100Alpha999',
+ '100Bravo1',
+ '100Bravo9',
+ '100Bravo10',
+ '100Bravo99',
+ '100Bravo100',
+ '100Bravo999',
+ '100Charlie1',
+ '100Charlie9',
+ '100Charlie10',
+ '100Charlie99',
+ '100Charlie100',
+ '100Charlie999',
+ '999Alpha1',
+ '999Alpha9',
+ '999Alpha10',
+ '999Alpha99',
+ '999Alpha100',
+ '999Alpha999',
+ '999Bravo1',
+ '999Bravo9',
+ '999Bravo10',
+ '999Bravo99',
+ '999Bravo100',
+ '999Bravo999',
+ '999Charlie1',
+ '999Charlie9',
+ '999Charlie10',
+ '999Charlie99',
+ '999Charlie100',
+ '999Charlie999',
+ ])
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 9e96a66fd..2ba5fa4ba 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -1,8 +1,10 @@
from __future__ import unicode_literals
import datetime
+import json
import six
+from django.core.serializers import serialize
from django.http import HttpResponse
@@ -71,3 +73,39 @@ def foreground_color(bg_color):
return '000000'
else:
return 'ffffff'
+
+
+def dynamic_import(name):
+ """
+ Dynamically import a class from an absolute path string
+ """
+ components = name.split('.')
+ mod = __import__(components[0])
+ for comp in components[1:]:
+ mod = getattr(mod, comp)
+ return mod
+
+
+def serialize_object(obj, extra=None):
+ """
+ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
+ change logging, not the REST API.) Optionally include a dictionary to supplement the object data.
+ """
+ json_str = serialize('json', [obj])
+ data = json.loads(json_str)[0]['fields']
+
+ # Include any custom fields
+ if hasattr(obj, 'get_custom_fields'):
+ data['custom_fields'] = {
+ field.name: str(value) for field, value in obj.get_custom_fields().items()
+ }
+
+ # Include any tags
+ # if hasattr(obj, 'tags'):
+ # data['tags'] = [tag.name for tag in obj.tags.all()]
+
+ # Append any extra data
+ if extra is not None:
+ data.update(extra)
+
+ return data
diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py
index dcdb9bc6d..102e368a5 100644
--- a/netbox/utilities/validators.py
+++ b/netbox/utilities/validators.py
@@ -9,7 +9,6 @@ class EnhancedURLValidator(URLValidator):
"""
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension.
"""
-
class AnyURLScheme(object):
"""
A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index dcb4529b1..e11d681ef 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -9,7 +9,7 @@ from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
-from django.db.models import ProtectedError
+from django.db.models import Count, ProtectedError
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponseServerError
from django.shortcuts import get_object_or_404, redirect, render
@@ -24,7 +24,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME
from django.views.generic import View
from django_tables2 import RequestConfig
-from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
+from extras.models import CustomField, CustomFieldValue, ExportTemplate
from utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField
from .constants import M2M_FIELD_TYPES
@@ -56,14 +56,22 @@ class GetReturnURLMixin(object):
"""
default_return_url = None
- def get_return_url(self, request, obj):
+ def get_return_url(self, request, obj=None):
+
+ # First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe.
query_param = request.GET.get('return_url')
if query_param and is_safe_url(url=query_param, host=request.get_host()):
return query_param
- elif obj.pk and hasattr(obj, 'get_absolute_url'):
+
+ # Next, check if the object being modified (if any) has an absolute URL.
+ elif obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url()
+
+ # Fall back to the default URL (if specified) for the view.
elif self.default_return_url is not None:
return reverse(self.default_return_url)
+
+ # If all else fails, return home. Ideally this should never happen.
return reverse('home')
@@ -124,6 +132,12 @@ class ObjectListView(View):
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
+ # Construct queryset for tags list
+ if hasattr(model, 'tags'):
+ tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name')
+ else:
+ tags = None
+
# Apply the request context
paginate = {
'klass': EnhancedPaginator,
@@ -136,6 +150,7 @@ class ObjectListView(View):
'table': table,
'permissions': permissions,
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
+ 'tags': tags,
}
context.update(self.extra_context())
@@ -156,7 +171,6 @@ class ObjectEditView(GetReturnURLMixin, View):
model: The model of the object being edited
model_form: The form used to create or edit the object
template_name: The name of the template
- default_return_url: The name of the URL used to display a list of this object type
"""
model = None
model_form = None
@@ -200,17 +214,15 @@ class ObjectEditView(GetReturnURLMixin, View):
obj_created = not form.instance.pk
obj = form.save()
- msg = 'Created ' if obj_created else 'Modified '
- msg += self.model._meta.verbose_name
+ msg = '{} {}'.format(
+ 'Created' if obj_created else 'Modified',
+ self.model._meta.verbose_name
+ )
if hasattr(obj, 'get_absolute_url'):
msg = '{} {} '.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
- if obj_created:
- UserAction.objects.log_create(request.user, obj, msg)
- else:
- UserAction.objects.log_edit(request.user, obj, msg)
if '_addanother' in request.POST:
return redirect(request.get_full_path())
@@ -235,7 +247,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
model: The model of the object being deleted
template_name: The name of the template
- default_return_url: Name of the URL to which the user is redirected after deleting the object
"""
model = None
template_name = 'utilities/obj_delete.html'
@@ -273,7 +284,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg)
- UserAction.objects.log_delete(request.user, obj, msg)
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
@@ -289,20 +299,19 @@ class ObjectDeleteView(GetReturnURLMixin, View):
})
-class BulkCreateView(View):
+class BulkCreateView(GetReturnURLMixin, View):
"""
Create new objects in bulk.
form: Form class which provides the `pattern` field
model_form: The ModelForm used to create individual objects
+ pattern_target: Name of the field to be evaluated as a pattern (if any)
template_name: The name of the template
- default_return_url: Name of the URL to which the user is redirected after creating the objects
"""
form = None
model_form = None
pattern_target = ''
template_name = None
- default_return_url = 'home'
def get(self, request):
@@ -319,7 +328,7 @@ class BulkCreateView(View):
'obj_type': self.model_form._meta.model._meta.verbose_name,
'form': form,
'model_form': model_form,
- 'return_url': reverse(self.default_return_url),
+ 'return_url': self.get_return_url(request),
})
def post(self, request):
@@ -359,11 +368,10 @@ class BulkCreateView(View):
# If we make it to this point, validation has succeeded on all new objects.
msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
messages.success(request, msg)
- UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST:
return redirect(request.path)
- return redirect(self.default_return_url)
+ return redirect(self.get_return_url(request))
except IntegrityError:
pass
@@ -372,23 +380,21 @@ class BulkCreateView(View):
'form': form,
'model_form': model_form,
'obj_type': model._meta.verbose_name,
- 'return_url': reverse(self.default_return_url),
+ 'return_url': self.get_return_url(request),
})
-class BulkImportView(View):
+class BulkImportView(GetReturnURLMixin, View):
"""
Import objects in bulk (CSV format).
model_form: The form used to create each imported object
table: The django-tables2 Table used to render the list of imported objects
template_name: The name of the template
- default_return_url: The name of the URL to use for the cancel button
widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
"""
model_form = None
table = None
- default_return_url = None
template_name = 'utilities/obj_import.html'
widget_attrs = {}
@@ -414,7 +420,7 @@ class BulkImportView(View):
'form': self._import_form(),
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
- 'return_url': self.default_return_url,
+ 'return_url': self.get_return_url(request),
})
def post(self, request):
@@ -444,11 +450,10 @@ class BulkImportView(View):
if new_objs:
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
messages.success(request, msg)
- UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
return render(request, "import_success.html", {
'table': obj_table,
- 'return_url': self.default_return_url,
+ 'return_url': self.get_return_url(request),
})
except ValidationError:
@@ -458,61 +463,49 @@ class BulkImportView(View):
'form': form,
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
- 'return_url': self.default_return_url,
+ 'return_url': self.get_return_url(request),
})
-class BulkEditView(View):
+class BulkEditView(GetReturnURLMixin, View):
"""
Edit objects in bulk.
- cls: The model of the objects being edited
- parent_cls: The model of the parent object (if any)
queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ parent_model: The model of the parent object (if any)
filter: FilterSet to apply when deleting by QuerySet
table: The table used to display devices being edited
form: The form class used to edit objects in bulk
template_name: The name of the template
- default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by
- POSTing return_url)
"""
- cls = None
- parent_cls = None
queryset = None
+ parent_model = None
filter = None
table = None
form = None
template_name = 'utilities/obj_bulk_edit.html'
- default_return_url = 'home'
def get(self, request):
- return redirect(self.default_return_url)
+ return redirect(self.get_return_url(request))
def post(self, request, **kwargs):
+ model = self.queryset.model
+
# Attempt to derive parent object if a parent class has been given
- if self.parent_cls:
- parent_obj = get_object_or_404(self.parent_cls, **kwargs)
+ if self.parent_model:
+ parent_obj = get_object_or_404(self.parent_model, **kwargs)
else:
parent_obj = None
- # Determine URL to redirect users upon modification of objects
- posted_return_url = request.POST.get('return_url')
- if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
- return_url = posted_return_url
- elif parent_obj:
- return_url = parent_obj.get_absolute_url()
- else:
- return_url = reverse(self.default_return_url)
-
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filter is not None:
- pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
+ pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST:
- form = self.form(self.cls, parent_obj, request.POST)
+ form = self.form(model, parent_obj, request.POST)
if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
@@ -524,7 +517,7 @@ class BulkEditView(View):
with transaction.atomic():
updated_count = 0
- for obj in self.cls.objects.filter(pk__in=pk_list):
+ for obj in model.objects.filter(pk__in=pk_list):
# Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields:
@@ -536,7 +529,7 @@ class BulkEditView(View):
obj.save()
# Update custom fields
- obj_type = ContentType.objects.get_for_model(self.cls)
+ obj_type = ContentType.objects.get_for_model(model)
for name in custom_fields:
field = form.fields[name].model
if name in form.nullable_fields and name in nullified_fields:
@@ -555,14 +548,19 @@ class BulkEditView(View):
cfv.value = form.cleaned_data[name]
cfv.save()
+ # Add/remove tags
+ if form.cleaned_data.get('add_tags', None):
+ obj.tags.add(*form.cleaned_data['add_tags'])
+ if form.cleaned_data.get('remove_tags', None):
+ obj.tags.remove(*form.cleaned_data['remove_tags'])
+
updated_count += 1
if updated_count:
- msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
+ msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural)
messages.success(self.request, msg)
- UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
- return redirect(return_url)
+ return redirect(self.get_return_url(request))
except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, e))
@@ -570,72 +568,59 @@ class BulkEditView(View):
else:
initial_data = request.POST.copy()
initial_data['pk'] = pk_list
- form = self.form(self.cls, parent_obj, initial=initial_data)
+ form = self.form(model, parent_obj, initial=initial_data)
# Retrieve objects being edited
- queryset = self.queryset or self.cls.objects.all()
- table = self.table(queryset.filter(pk__in=pk_list), orderable=False)
+ table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
if not table.rows:
- messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural))
- return redirect(return_url)
+ messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
+ return redirect(self.get_return_url(request))
return render(request, self.template_name, {
'form': form,
'table': table,
- 'obj_type_plural': self.cls._meta.verbose_name_plural,
- 'return_url': return_url,
+ 'obj_type_plural': model._meta.verbose_name_plural,
+ 'return_url': self.get_return_url(request),
})
-class BulkDeleteView(View):
+class BulkDeleteView(GetReturnURLMixin, View):
"""
Delete objects in bulk.
- cls: The model of the objects being deleted
- parent_cls: The model of the parent object (if any)
queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ parent_model: The model of the parent object (if any)
filter: FilterSet to apply when deleting by QuerySet
table: The table used to display devices being deleted
form: The form class used to delete objects in bulk
template_name: The name of the template
- default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by
- POSTing return_url)
"""
- cls = None
- parent_cls = None
queryset = None
+ parent_model = None
filter = None
table = None
form = None
template_name = 'utilities/obj_bulk_delete.html'
- default_return_url = 'home'
def get(self, request):
- return redirect(self.default_return_url)
+ return redirect(self.get_return_url(request))
def post(self, request, **kwargs):
+ model = self.queryset.model
+
# Attempt to derive parent object if a parent class has been given
- if self.parent_cls:
- parent_obj = get_object_or_404(self.parent_cls, **kwargs)
+ if self.parent_model:
+ parent_obj = get_object_or_404(self.parent_model, **kwargs)
else:
parent_obj = None
- # Determine URL to redirect users upon deletion of objects
- posted_return_url = request.POST.get('return_url')
- if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
- return_url = posted_return_url
- elif parent_obj:
- return_url = parent_obj.get_absolute_url()
- else:
- return_url = reverse(self.default_return_url)
-
# Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
if self.filter is not None:
- pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs]
+ pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs]
else:
- pk_list = self.cls.objects.values_list('pk', flat=True)
+ pk_list = model.objects.values_list('pk', flat=True)
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
@@ -646,34 +631,35 @@ class BulkDeleteView(View):
if form.is_valid():
# Delete objects
- queryset = self.cls.objects.filter(pk__in=pk_list)
+ queryset = model.objects.filter(pk__in=pk_list)
try:
- deleted_count = queryset.delete()[1][self.cls._meta.label]
+ deleted_count = queryset.delete()[1][model._meta.label]
except ProtectedError as e:
handle_protectederror(list(queryset), request, e)
- return redirect(return_url)
+ return redirect(self.get_return_url(request))
- msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
+ msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
messages.success(request, msg)
- UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
- return redirect(return_url)
+ return redirect(self.get_return_url(request))
else:
- form = form_cls(initial={'pk': pk_list, 'return_url': return_url})
+ form = form_cls(initial={
+ 'pk': pk_list,
+ 'return_url': self.get_return_url(request),
+ })
# Retrieve objects being deleted
- queryset = self.queryset or self.cls.objects.all()
- table = self.table(queryset.filter(pk__in=pk_list), orderable=False)
+ table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
if not table.rows:
- messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
- return redirect(return_url)
+ messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
+ return redirect(self.get_return_url(request))
return render(request, self.template_name, {
'form': form,
'parent_obj': parent_obj,
- 'obj_type_plural': self.cls._meta.verbose_name_plural,
+ 'obj_type_plural': model._meta.verbose_name_plural,
'table': table,
- 'return_url': return_url,
+ 'return_url': self.get_return_url(request),
})
def get_form(self):
@@ -682,7 +668,7 @@ class BulkDeleteView(View):
"""
class BulkDeleteForm(ConfirmationForm):
- pk = ModelMultipleChoiceField(queryset=self.cls.objects.all(), widget=MultipleHiddenInput)
+ pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
if self.form:
return self.form
@@ -786,7 +772,7 @@ class ComponentCreateView(View):
})
-class BulkComponentCreateView(View):
+class BulkComponentCreateView(GetReturnURLMixin, View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
"""
@@ -798,7 +784,6 @@ class BulkComponentCreateView(View):
filter = None
table = None
template_name = 'utilities/obj_bulk_add_component.html'
- default_return_url = 'home'
def post(self, request):
@@ -808,17 +793,10 @@ class BulkComponentCreateView(View):
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
- # Determine URL to redirect users upon modification of objects
- posted_return_url = request.POST.get('return_url')
- if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
- return_url = posted_return_url
- else:
- return_url = reverse(self.default_return_url)
-
selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
if not selected_objects:
messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
- return redirect(return_url)
+ return redirect(self.get_return_url(request))
table = self.table(selected_objects)
if '_create' in request.POST:
@@ -846,13 +824,14 @@ class BulkComponentCreateView(View):
if not form.errors:
self.model.objects.bulk_create(new_components)
+
messages.success(request, "Added {} {} to {} {}.".format(
len(new_components),
self.model._meta.verbose_name_plural,
len(form.cleaned_data['pk']),
self.parent_model._meta.verbose_name_plural
))
- return redirect(return_url)
+ return redirect(self.get_return_url(request))
else:
form = self.form(initial={'pk': pk_list})
@@ -861,7 +840,7 @@ class BulkComponentCreateView(View):
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'table': table,
- 'return_url': reverse(self.default_return_url),
+ 'return_url': self.get_return_url(request),
})
diff --git a/netbox/virtualization/__init__.py b/netbox/virtualization/__init__.py
index e69de29bb..3f12ae450 100644
--- a/netbox/virtualization/__init__.py
+++ b/netbox/virtualization/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'virtualization.apps.VirtualizationConfig'
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index c03cdc166..9664e9218 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -1,14 +1,15 @@
from __future__ import unicode_literals
from rest_framework import serializers
+from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
-from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
+from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer
from virtualization.constants import VM_STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -24,7 +25,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedClusterTypeSerializer(serializers.ModelSerializer):
+class NestedClusterTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
class Meta:
@@ -43,7 +44,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedClusterGroupSerializer(serializers.ModelSerializer):
+class NestedClusterGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
class Meta:
@@ -55,17 +56,20 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer):
# Clusters
#
-class ClusterSerializer(CustomFieldModelSerializer):
+class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
- group = NestedClusterGroupSerializer()
- site = NestedSiteSerializer()
+ group = NestedClusterGroupSerializer(required=False, allow_null=True)
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ tags = TagListSerializerField(required=False)
class Meta:
model = Cluster
- fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
+ fields = [
+ 'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ ]
-class NestedClusterSerializer(serializers.ModelSerializer):
+class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
@@ -73,13 +77,6 @@ class NestedClusterSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name']
-class WritableClusterSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Cluster
- fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
-
-
#
# Virtual machines
#
@@ -93,25 +90,39 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'address']
-class VirtualMachineSerializer(CustomFieldModelSerializer):
- status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
- cluster = NestedClusterSerializer()
- role = NestedDeviceRoleSerializer()
- tenant = NestedTenantSerializer()
- platform = NestedPlatformSerializer()
- primary_ip = VirtualMachineIPAddressSerializer()
- primary_ip4 = VirtualMachineIPAddressSerializer()
- primary_ip6 = VirtualMachineIPAddressSerializer()
+class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
+ status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
+ cluster = NestedClusterSerializer(required=False, allow_null=True)
+ role = NestedDeviceRoleSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ platform = NestedPlatformSerializer(required=False, allow_null=True)
+ primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
+ primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
+ primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
+ tags = TagListSerializerField(required=False)
class Meta:
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
- 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
+ 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
-class NestedVirtualMachineSerializer(serializers.ModelSerializer):
+class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
+ config_context = serializers.SerializerMethodField()
+
+ class Meta(VirtualMachineSerializer.Meta):
+ fields = [
+ 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
+ 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
+ ]
+
+ def get_config_context(self, obj):
+ return obj.get_config_context()
+
+
+class NestedVirtualMachineSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
class Meta:
@@ -119,22 +130,12 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name']
-class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = VirtualMachine
- fields = [
- 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
- 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
- ]
-
-
#
# VM interfaces
#
# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
-class InterfaceVLANSerializer(serializers.ModelSerializer):
+class InterfaceVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
@@ -142,34 +143,30 @@ class InterfaceVLANSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
-class InterfaceSerializer(serializers.ModelSerializer):
+class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
- mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
- untagged_vlan = InterfaceVLANSerializer()
- tagged_vlans = InterfaceVLANSerializer(many=True)
+ form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False)
+ mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False)
+ untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
+ tagged_vlans = SerializedPKRelatedField(
+ queryset=VLAN.objects.all(),
+ serializer=InterfaceVLANSerializer,
+ required=False,
+ many=True
+ )
+ tags = TagListSerializerField(required=False)
class Meta:
model = Interface
fields = [
- 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans',
- 'description',
+ 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
+ 'untagged_vlan', 'tagged_vlans', 'tags',
]
-class NestedInterfaceSerializer(serializers.ModelSerializer):
+class NestedInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'name']
-
-
-class WritableInterfaceSerializer(ValidatedModelSerializer):
- form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL)
-
- class Meta:
- model = Interface
- fields = [
- 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan',
- 'tagged_vlans', 'description',
- ]
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 149bb3145..01b8792c8 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -37,7 +37,6 @@ class ClusterGroupViewSet(ModelViewSet):
class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.select_related('type', 'group')
serializer_class = serializers.ClusterSerializer
- write_serializer_class = serializers.WritableClusterSerializer
filter_class = filters.ClusterFilter
@@ -47,13 +46,18 @@ class ClusterViewSet(CustomFieldModelViewSet):
class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all()
- serializer_class = serializers.VirtualMachineSerializer
- write_serializer_class = serializers.WritableVirtualMachineSerializer
filter_class = filters.VirtualMachineFilter
+ def get_serializer_class(self):
+ """
+ Include rendered config context when retrieving a single VirtualMachine.
+ """
+ if self.action == 'retrieve':
+ return serializers.VirtualMachineWithConfigContextSerializer
+ return serializers.VirtualMachineSerializer
+
class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
serializer_class = serializers.InterfaceSerializer
- write_serializer_class = serializers.WritableInterfaceSerializer
filter_class = filters.InterfaceFilter
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index 53c3f18d9..6af4e4a22 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Site (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Cluster
@@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Platform (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = VirtualMachine
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 4dfea1b42..10833234b 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -4,12 +4,13 @@ from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count
from mptt.forms import TreeNodeChoiceField
+from taggit.forms import TagField
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
-from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
@@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm):
class ClusterForm(BootstrapMixin, CustomFieldForm):
comments = CommentField(widget=SmallTextarea)
+ tags = TagField(required=False)
class Meta:
model = Cluster
- fields = ['name', 'type', 'group', 'site', 'comments']
+ fields = ['name', 'type', 'group', 'site', 'comments', 'tags']
class ClusterCSVForm(forms.ModelForm):
@@ -117,7 +119,7 @@ class ClusterCSVForm(forms.ModelForm):
fields = Cluster.csv_headers
-class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False)
@@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
)
)
+ tags = TagField(required=False)
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
- 'vcpus', 'memory', 'disk', 'comments',
+ 'vcpus', 'memory', 'disk', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -346,7 +349,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
fields = VirtualMachine.csv_headers
-class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='')
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py
index cb553cf95..a5c7535cf 100644
--- a/netbox/virtualization/migrations/0001_virtualization.py
+++ b/netbox/virtualization/migrations/0001_virtualization.py
@@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
-import extras.models
class Migration(migrations.Migration):
@@ -30,7 +29,6 @@ class Migration(migrations.Migration):
options={
'ordering': ['name'],
},
- bases=(models.Model, extras.models.CustomFieldModel),
),
migrations.CreateModel(
name='ClusterGroup',
@@ -74,7 +72,6 @@ class Migration(migrations.Migration):
options={
'ordering': ['name'],
},
- bases=(models.Model, extras.models.CustomFieldModel),
),
migrations.AddField(
model_name='cluster',
diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py
new file mode 100644
index 000000000..295ec7d17
--- /dev/null
+++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.14 on 2018-07-31 02:23
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ replaces = [('virtualization', '0002_virtualmachine_add_status'), ('virtualization', '0003_cluster_add_site'), ('virtualization', '0004_virtualmachine_add_role')]
+
+ dependencies = [
+ ('dcim', '0044_virtualization'),
+ ('virtualization', '0001_virtualization'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='status',
+ field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'),
+ ),
+ migrations.AddField(
+ model_name='cluster',
+ name='site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='role',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0005_django2.py b/netbox/virtualization/migrations/0005_django2.py
new file mode 100644
index 000000000..e79a55350
--- /dev/null
+++ b/netbox/virtualization/migrations/0005_django2.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.3 on 2018-03-30 14:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0004_virtualmachine_add_role'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='role',
+ field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0006_tags.py b/netbox/virtualization/migrations/0006_tags.py
new file mode 100644
index 000000000..eed800852
--- /dev/null
+++ b/netbox/virtualization/migrations/0006_tags.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('virtualization', '0005_django2'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0007_change_logging.py b/netbox/virtualization/migrations/0007_change_logging.py
new file mode 100644
index 000000000..954f9f2a9
--- /dev/null
+++ b/netbox/virtualization/migrations/0007_change_logging.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0006_tags'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clustergroup',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='clustergroup',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='clustertype',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='clustertype',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 0a6abc400..3d8a51fff 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -6,10 +6,11 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
from dcim.models import Device
-from extras.models import CustomFieldModel, CustomFieldValue
-from utilities.models import CreatedUpdatedModel
+from extras.models import ConfigContextModel, CustomFieldModel
+from utilities.models import ChangeLoggedModel
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
@@ -18,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE
#
@python_2_unicode_compatible
-class ClusterType(models.Model):
+class ClusterType(ChangeLoggedModel):
"""
A type of Cluster.
"""
@@ -53,7 +54,7 @@ class ClusterType(models.Model):
#
@python_2_unicode_compatible
-class ClusterGroup(models.Model):
+class ClusterGroup(ChangeLoggedModel):
"""
An organizational group of Clusters.
"""
@@ -88,7 +89,7 @@ class ClusterGroup(models.Model):
#
@python_2_unicode_compatible
-class Cluster(CreatedUpdatedModel, CustomFieldModel):
+class Cluster(ChangeLoggedModel, CustomFieldModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
"""
@@ -119,11 +120,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
blank=True
)
custom_field_values = GenericRelation(
- to=CustomFieldValue,
+ to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager()
+
csv_headers = ['name', 'type', 'group', 'site', 'comments']
class Meta:
@@ -162,12 +165,12 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
#
@python_2_unicode_compatible
-class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
+class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
"""
A virtual machine which runs inside a Cluster.
"""
cluster = models.ForeignKey(
- to=Cluster,
+ to='virtualization.Cluster',
on_delete=models.PROTECT,
related_name='virtual_machines'
)
@@ -196,9 +199,9 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
)
role = models.ForeignKey(
to='dcim.DeviceRole',
- limit_choices_to={'vm_role': True},
on_delete=models.PROTECT,
related_name='virtual_machines',
+ limit_choices_to={'vm_role': True},
blank=True,
null=True
)
@@ -237,11 +240,13 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
blank=True
)
custom_field_values = GenericRelation(
- to=CustomFieldValue,
+ to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager()
+
csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index 4d38a3fe5..84579af49 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -9,12 +9,18 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """
+
+
+
{% if perms.virtualization.change_clustertype %}
{% endif %}
"""
CLUSTERGROUP_ACTIONS = """
+
+
+
{% if perms.virtualization.change_clustergroup %}
{% endif %}
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 1f9e72ee5..b397e9dff 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -1,22 +1,20 @@
from __future__ import unicode_literals
-from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
-from rest_framework.test import APITestCase
-from users.models import Token
-from utilities.tests import HttpStatusMixin
+from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED
+from dcim.models import Interface
+from ipam.models import VLAN
+from utilities.testing import APITestCase
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
-class ClusterTypeTest(HttpStatusMixin, APITestCase):
+class ClusterTypeTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(ClusterTypeTest, self).setUp()
self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
@@ -103,13 +101,11 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
self.assertEqual(ClusterType.objects.count(), 2)
-class ClusterGroupTest(HttpStatusMixin, APITestCase):
+class ClusterGroupTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(ClusterGroupTest, self).setUp()
self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
@@ -196,13 +192,11 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(ClusterGroup.objects.count(), 2)
-class ClusterTest(HttpStatusMixin, APITestCase):
+class ClusterTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(ClusterTest, self).setUp()
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
@@ -301,13 +295,11 @@ class ClusterTest(HttpStatusMixin, APITestCase):
self.assertEqual(Cluster.objects.count(), 2)
-class VirtualMachineTest(HttpStatusMixin, APITestCase):
+class VirtualMachineTest(APITestCase):
def setUp(self):
- user = User.objects.create(username='testuser', is_superuser=True)
- token = Token.objects.create(user=user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+ super(VirtualMachineTest, self).setUp()
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
@@ -401,3 +393,168 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(VirtualMachine.objects.count(), 2)
+
+
+class InterfaceTest(APITestCase):
+
+ def setUp(self):
+
+ super(InterfaceTest, self).setUp()
+
+ clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
+ cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
+ self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
+ self.interface1 = Interface.objects.create(
+ virtual_machine=self.virtualmachine,
+ name='Test Interface 1',
+ form_factor=IFACE_FF_VIRTUAL
+ )
+ self.interface2 = Interface.objects.create(
+ virtual_machine=self.virtualmachine,
+ name='Test Interface 2',
+ form_factor=IFACE_FF_VIRTUAL
+ )
+ self.interface3 = Interface.objects.create(
+ virtual_machine=self.virtualmachine,
+ name='Test Interface 3',
+ form_factor=IFACE_FF_VIRTUAL
+ )
+
+ self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
+ self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
+ self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
+
+ def test_get_interface(self):
+
+ url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(response.data['name'], self.interface1.name)
+
+ def test_list_interfaces(self):
+
+ url = reverse('virtualization-api:interface-list')
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(response.data['count'], 3)
+
+ def test_create_interface(self):
+
+ data = {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 4',
+ }
+
+ url = reverse('virtualization-api:interface-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Interface.objects.count(), 4)
+ interface4 = Interface.objects.get(pk=response.data['id'])
+ self.assertEqual(interface4.virtual_machine_id, data['virtual_machine'])
+ self.assertEqual(interface4.name, data['name'])
+
+ def test_create_interface_with_802_1q(self):
+
+ data = {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 4',
+ 'mode': IFACE_MODE_TAGGED,
+ 'untagged_vlan': self.vlan3.id,
+ 'tagged_vlans': [self.vlan1.id, self.vlan2.id],
+ }
+
+ url = reverse('virtualization-api:interface-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Interface.objects.count(), 4)
+ self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine'])
+ self.assertEqual(response.data['name'], data['name'])
+ self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
+ self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans'])
+
+ def test_create_interface_bulk(self):
+
+ data = [
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 4',
+ },
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 5',
+ },
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 6',
+ },
+ ]
+
+ url = reverse('virtualization-api:interface-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Interface.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
+ def test_create_interface_802_1q_bulk(self):
+
+ data = [
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 4',
+ 'mode': IFACE_MODE_TAGGED,
+ 'untagged_vlan': self.vlan2.id,
+ 'tagged_vlans': [self.vlan1.id],
+ },
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 5',
+ 'mode': IFACE_MODE_TAGGED,
+ 'untagged_vlan': self.vlan2.id,
+ 'tagged_vlans': [self.vlan1.id],
+ },
+ {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface 6',
+ 'mode': IFACE_MODE_TAGGED,
+ 'untagged_vlan': self.vlan2.id,
+ 'tagged_vlans': [self.vlan1.id],
+ },
+ ]
+
+ url = reverse('virtualization-api:interface-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Interface.objects.count(), 6)
+ for i in range(0, 3):
+ self.assertEqual(response.data[i]['name'], data[i]['name'])
+ self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
+ self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan'])
+
+ def test_update_interface(self):
+
+ data = {
+ 'virtual_machine': self.virtualmachine.pk,
+ 'name': 'Test Interface X',
+ }
+
+ url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
+ response = self.client.put(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(Interface.objects.count(), 3)
+ interface1 = Interface.objects.get(pk=response.data['id'])
+ self.assertEqual(interface1.name, data['name'])
+
+ def test_delete_interface(self):
+
+ url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
+ response = self.client.delete(url, **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+ self.assertEqual(Interface.objects.count(), 2)
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py
index 2ba0daff7..b03b3bc0a 100644
--- a/netbox/virtualization/urls.py
+++ b/netbox/virtualization/urls.py
@@ -2,8 +2,10 @@ from __future__ import unicode_literals
from django.conf.urls import url
+from extras.views import ObjectChangeLogView
from ipam.views import ServiceCreateView
from . import views
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
app_name = 'virtualization'
urlpatterns = [
@@ -14,6 +16,7 @@ urlpatterns = [
url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
url(r'^cluster-types/(?P[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
+ url(r'^cluster-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
# Cluster groups
url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
@@ -21,6 +24,7 @@ urlpatterns = [
url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
url(r'^cluster-groups/(?P[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
+ url(r'^cluster-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
# Clusters
url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
@@ -31,6 +35,7 @@ urlpatterns = [
url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'),
url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
+ url(r'^clusters/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
url(r'^clusters/(?P\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
url(r'^clusters/(?P\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
@@ -43,6 +48,8 @@ urlpatterns = [
url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
+ url(r'^virtual-machines/(?P\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
+ url(r'^virtual-machines/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 5aef710c1..4ddacce40 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -9,6 +9,7 @@ from django.views.generic import View
from dcim.models import Device, Interface
from dcim.tables import DeviceTable
+from extras.views import ObjectConfigContextView
from ipam.models import Service
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
@@ -32,9 +33,7 @@ class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_clustertype'
model = ClusterType
model_form = forms.ClusterTypeForm
-
- def get_return_url(self, request, obj):
- return reverse('virtualization:clustertype_list')
+ default_return_url = 'virtualization:clustertype_list'
class ClusterTypeEditView(ClusterTypeCreateView):
@@ -50,7 +49,6 @@ class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustertype'
- cls = ClusterType
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list'
@@ -70,9 +68,7 @@ class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_clustergroup'
model = ClusterGroup
model_form = forms.ClusterGroupForm
-
- def get_return_url(self, request, obj):
- return reverse('virtualization:clustergroup_list')
+ default_return_url = 'virtualization:clustergroup_list'
class ClusterGroupEditView(ClusterGroupCreateView):
@@ -88,7 +84,6 @@ class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustergroup'
- cls = ClusterGroup
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list'
@@ -99,7 +94,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class ClusterListView(ObjectListView):
- queryset = Cluster.objects.select_related('type', 'group')
+ queryset = Cluster.objects.select_related('type', 'group', 'site')
table = tables.ClusterTable
filter = filters.ClusterFilter
filter_form = forms.ClusterFilterForm
@@ -126,6 +121,7 @@ class ClusterView(View):
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_cluster'
+ template_name = 'virtualization/cluster_edit.html'
model = Cluster
model_form = forms.ClusterForm
@@ -149,7 +145,7 @@ class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'virtualization.change_cluster'
- cls = Cluster
+ queryset = Cluster.objects.select_related('type', 'group', 'site')
filter = filters.ClusterFilter
table = tables.ClusterTable
form = forms.ClusterBulkEditForm
@@ -158,8 +154,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_cluster'
- cls = Cluster
- queryset = Cluster.objects.all()
+ queryset = Cluster.objects.select_related('type', 'group', 'site')
filter = filters.ClusterFilter
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'
@@ -246,7 +241,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
#
class VirtualMachineListView(ObjectListView):
- queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'primary_ip4', 'primary_ip6')
+ queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
filter = filters.VirtualMachineFilter
filter_form = forms.VirtualMachineFilterForm
table = tables.VirtualMachineDetailTable
@@ -257,17 +252,22 @@ class VirtualMachineView(View):
def get(self, request, pk):
- vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
- interfaces = Interface.objects.filter(virtual_machine=vm)
- services = Service.objects.filter(virtual_machine=vm)
+ virtualmachine = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
+ interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
+ services = Service.objects.filter(virtual_machine=virtualmachine)
return render(request, 'virtualization/virtualmachine.html', {
- 'vm': vm,
+ 'virtualmachine': virtualmachine,
'interfaces': interfaces,
'services': services,
})
+class VirtualMachineConfigContextView(ObjectConfigContextView):
+ object_class = VirtualMachine
+ base_template = 'virtualization/virtualmachine.html'
+
+
class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_virtualmachine'
model = VirtualMachine
@@ -295,8 +295,7 @@ class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'virtualization.change_virtualmachine'
- cls = VirtualMachine
- queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
+ queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role')
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable
form = forms.VirtualMachineBulkEditForm
@@ -305,8 +304,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
- cls = VirtualMachine
- queryset = VirtualMachine.objects.select_related('cluster', 'tenant')
+ queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role')
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'
@@ -340,16 +338,16 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
- cls = Interface
- parent_cls = VirtualMachine
+ queryset = Interface.objects.all()
+ parent_model = VirtualMachine
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
- cls = Interface
- parent_cls = VirtualMachine
+ queryset = Interface.objects.all()
+ parent_model = VirtualMachine
table = tables.InterfaceTable
diff --git a/requirements.txt b/requirements.txt
index 8d7e251af..b3bee6b6d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,20 +1,23 @@
-Django>=1.11,<2.0
-django-cors-headers>=2.1.0
-django-debug-toolbar>=1.9.0
+Django>=1.11,<2.1
+django-cors-headers==2.4.0
+django-debug-toolbar==1.9.1
django-filter==1.1.0
-django-mptt>=0.9.0
-django-tables2>=1.19.0
-django-timezone-field>=2.0
-djangorestframework>=3.7.7
-drf-yasg[validation]>=1.4.4
-graphviz>=0.8.2
-Markdown>=2.6.11
-natsort>=5.2.0
-ncclient==0.5.3
-netaddr==0.7.18
-paramiko>=2.4.0
-Pillow>=5.0.0
-psycopg2-binary>=2.7.4
-py-gfm>=0.1.3
-pycryptodome>=3.4.11
-xmltodict>=0.11.0
+django-mptt==0.9.1
+django-tables2==1.21.2
+django-taggit==0.22.2
+django-taggit-serializer==0.1.7
+django-timezone-field==2.1
+djangorestframework==3.8.1
+drf-yasg[validation]==1.9.2
+graphviz==0.8.4
+Markdown==2.6.11
+natsort==5.3.3
+ncclient==0.6.0
+netaddr==0.7.19
+paramiko==2.4.1
+Pillow==5.2.0
+psycopg2-binary==2.7.5
+py-gfm==0.1.3
+pycryptodome==3.6.4
+xmltodict==0.11.0
+