- {% if secret.pk %}
+ {% if secret.pk and secret|decryptable_by:request.user %}
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
index b0468b37a..5b828b661 100644
--- a/netbox/tenancy/forms.py
+++ b/netbox/tenancy/forms.py
@@ -2,11 +2,11 @@ from django import forms
from taggit.forms import TagField
from extras.forms import (
- AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm,
+ AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
)
from utilities.forms import (
- APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
- FilterChoiceField, SlugField, TagFilterField
+ APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
+ DynamicModelMultipleChoiceField, SlugField, TagFilterField,
)
from .models import Tenant, TenantGroup
@@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm):
class TenantForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
+ group = DynamicModelChoiceField(
+ queryset=TenantGroup.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url="/api/tenancy/tenant-groups/"
+ )
+ )
comments = CommentField()
tags = TagField(
required=False
@@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = Tenant
- fields = [
+ fields = (
'name', 'slug', 'group', 'description', 'comments', 'tags',
- ]
- widgets = {
- 'group': APISelect(
- api_url="/api/tenancy/tenant-groups/"
- )
- }
+ )
class TenantCSVForm(CustomFieldModelForm):
@@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Tenant.objects.all(),
widget=forms.MultipleHiddenInput()
)
- group = forms.ModelChoiceField(
+ group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelect(
@@ -105,10 +107,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
- group = FilterChoiceField(
+ group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/",
value_field="slug",
@@ -122,8 +124,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Form extensions
#
-class TenancyForm(ChainedFieldsMixin, forms.Form):
- tenant_group = forms.ModelChoiceField(
+class TenancyForm(forms.Form):
+ tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelect(
@@ -136,11 +138,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
}
)
)
- tenant = ChainedModelChoiceField(
+ tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
- chains=(
- ('group', 'tenant_group'),
- ),
required=False,
widget=APISelect(
api_url='/api/tenancy/tenants/'
@@ -160,10 +159,10 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
class TenancyFilterForm(forms.Form):
- tenant_group = FilterChoiceField(
+ tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/",
value_field="slug",
@@ -173,10 +172,10 @@ class TenancyFilterForm(forms.Form):
}
)
)
- tenant = FilterChoiceField(
+ tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field="slug",
diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py
index a44ca2932..27e2c1591 100644
--- a/netbox/tenancy/tests/test_views.py
+++ b/netbox/tenancy/tests/test_views.py
@@ -1,15 +1,10 @@
from tenancy.models import Tenant, TenantGroup
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
-class TenantGroupTestCase(StandardTestCases.Views):
+class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = TenantGroup
- # Disable inapplicable tests
- test_get_object = None
- test_delete_object = None
- test_bulk_edit_objects = None
-
@classmethod
def setUpTestData(cls):
@@ -32,7 +27,7 @@ class TenantGroupTestCase(StandardTestCases.Views):
)
-class TenantTestCase(StandardTestCases.Views):
+class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tenant
@classmethod
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 5ef4156aa..95de2a25d 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
class ChoiceField(Field):
"""
- Represent a ChoiceField as {'value': , 'label': }.
+ Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write.
+
+ :param choices: An iterable of choices in the form (value, key).
+ :param allow_blank: Allow blank values in addition to the listed choices.
"""
- def __init__(self, choices, **kwargs):
+ def __init__(self, choices, allow_blank=False, **kwargs):
self.choiceset = choices
+ self.allow_blank = allow_blank
self._choices = dict()
# Unpack grouped choices
@@ -77,6 +81,15 @@ class ChoiceField(Field):
super().__init__(**kwargs)
+ def validate_empty_values(self, data):
+ # Convert null to an empty string unless allow_null == True
+ if data is None:
+ if self.allow_null:
+ return True, None
+ else:
+ data = ''
+ return super().validate_empty_values(data)
+
def to_representation(self, obj):
if obj is '':
return None
@@ -93,6 +106,10 @@ class ChoiceField(Field):
return data
def to_internal_value(self, data):
+ if data is '':
+ if self.allow_blank:
+ return data
+ raise ValidationError("This field may not be blank.")
# Provide an explicit error message if the request is trying to write a dict or list
if isinstance(data, (dict, list)):
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 355484673..c9a857ad0 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -8,7 +8,7 @@ from django import forms
from django.conf import settings
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.db.models import Count
-from mptt.forms import TreeNodeMultipleChoiceField
+from django.forms import BoundField
from .choices import unpack_grouped_choices
from .constants import *
@@ -211,7 +211,7 @@ class SelectWithPK(StaticSelect2):
option_template_name = 'widgets/select_option_with_pk.html'
-class ContentTypeSelect(forms.Select):
+class ContentTypeSelect(StaticSelect2):
"""
Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
@@ -259,9 +259,6 @@ class APISelect(SelectWithDisabled):
name of the query param and the value if the query param's value.
:param null_option: If true, include the static null option in the selection list.
"""
- # Only preload the selected option(s); new options are dynamically displayed and added via the API
- template_name = 'widgets/select_api.html'
-
def __init__(
self,
api_url,
@@ -525,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
return value
-class ChainedModelChoiceField(forms.ModelChoiceField):
- """
- A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
- mapping of model fields to peer fields within the form. For example:
-
- country1 = forms.ModelChoiceField(queryset=Country.objects.all())
- city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
-
- The queryset of the `city1` field will be modified as
-
- .filter(country=)
-
- where is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
- """
- def __init__(self, chains=None, *args, **kwargs):
- self.chains = chains
- super().__init__(*args, **kwargs)
-
-
-class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
- """
- See ChainedModelChoiceField
- """
- def __init__(self, chains=None, *args, **kwargs):
- self.chains = chains
- super().__init__(*args, **kwargs)
-
-
class SlugField(forms.SlugField):
"""
Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
@@ -581,46 +550,38 @@ class TagFilterField(forms.MultipleChoiceField):
super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
-class FilterChoiceIterator(forms.models.ModelChoiceIterator):
+class DynamicModelChoiceMixin:
+ field_modifier = ''
- def __iter__(self):
- # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string)
- if self.field.null_label is not None:
- yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label)
- queryset = self.queryset.all()
- # Can't use iterator() when queryset uses prefetch_related()
- if not queryset._prefetch_related_lookups:
- queryset = queryset.iterator()
- for obj in queryset:
- yield self.choice(obj)
+ def get_bound_field(self, form, field_name):
+ bound_field = BoundField(form, self, field_name)
+
+ # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
+ # will be populated on-demand via the APISelect widget.
+ field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier)
+ if bound_field.data:
+ self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)})
+ elif bound_field.initial:
+ self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)})
+ else:
+ self.queryset = self.queryset.none()
+
+ return bound_field
-class FilterChoiceFieldMixin(object):
- iterator = FilterChoiceIterator
-
- def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs):
- self.null_label = null_label
- self.count_attr = count_attr
- if 'required' not in kwargs:
- kwargs['required'] = False
- if 'widget' not in kwargs:
- kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
- super().__init__(*args, **kwargs)
-
- def label_from_instance(self, obj):
- label = super().label_from_instance(obj)
- obj_count = getattr(obj, self.count_attr, None)
- if obj_count is not None:
- return '{} ({})'.format(label, obj_count)
- return label
-
-
-class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
+class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
+ """
+ Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
+ rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
+ """
pass
-class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
- pass
+class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
+ """
+ A multiple-choice version of DynamicModelChoiceField.
+ """
+ field_modifier = '__in'
class LaxURLField(forms.URLField):
@@ -675,46 +636,6 @@ class BootstrapMixin(forms.BaseForm):
field.widget.attrs['placeholder'] = field.label
-class ChainedFieldsMixin(forms.BaseForm):
- """
- Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- for field_name, field in self.fields.items():
-
- if isinstance(field, ChainedModelChoiceField):
-
- filters_dict = {}
- for (db_field, parent_field) in field.chains:
- if self.is_bound and parent_field in self.data and self.data[parent_field]:
- filters_dict[db_field] = self.data[parent_field] or None
- elif self.initial.get(parent_field):
- filters_dict[db_field] = self.initial[parent_field]
- elif self.fields[parent_field].widget.attrs.get('nullable'):
- filters_dict[db_field] = None
- else:
- break
-
- # Limit field queryset by chained field values
- if filters_dict:
- field.queryset = field.queryset.filter(**filters_dict)
- # Editing an existing instance; limit field to its current value
- elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
- obj = getattr(self.instance, field_name)
- if obj is not None:
- field.queryset = field.queryset.filter(pk=obj.pk)
- else:
- field.queryset = field.queryset.none()
- # Creating a new instance with no bound data; nullify queryset
- elif not self.data.get(field_name):
- field.queryset = field.queryset.none()
- # Creating a new instance with bound data; limit queryset to the specified value
- else:
- field.queryset = field.queryset.filter(pk=self.data.get(field_name))
-
-
class ReturnURLForm(forms.Form):
"""
Provides a hidden return URL field to control where the user is directed after the form is submitted.
diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py
index d459e6f6c..a560e776e 100644
--- a/netbox/utilities/ordering.py
+++ b/netbox/utilities/ordering.py
@@ -68,7 +68,7 @@ def naturalize_interface(value, max_length=None):
if match.group('type') is not None:
output.append(match.group('type'))
- # Finally, append any remaining fields, left-padding to eight digits each.
+ # Finally, append any remaining fields, left-padding to six digits each.
for part_name in ('id', 'channel', 'vc'):
part = match.group(part_name)
if part is not None:
diff --git a/netbox/utilities/templates/widgets/select_api.html b/netbox/utilities/templates/widgets/select_api.html
deleted file mode 100644
index d9516086b..000000000
--- a/netbox/utilities/templates/widgets/select_api.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 4278b3b95..ae679c91a 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -82,7 +82,7 @@ def render_yaml(value):
"""
Render a dictionary as formatted YAML.
"""
- return yaml.dump(dict(value))
+ return yaml.dump(json.loads(json.dumps(value)))
@register.filter()
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index b5e2e1bab..8d1b1a1be 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -57,6 +57,53 @@ class TestCase(_TestCase):
expected_status, response.status_code, getattr(response, 'data', 'No data')
))
+
+class ModelViewTestCase(TestCase):
+ """
+ Base TestCase for model views. Subclass to test individual views.
+ """
+ model = None
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if self.model is None:
+ raise Exception("Test case requires model to be defined")
+
+ def _get_base_url(self):
+ """
+ Return the base format for a URL for the test's model. Override this to test for a model which belongs
+ to a different app (e.g. testing Interfaces within the virtualization app).
+ """
+ return '{}:{}_{{}}'.format(
+ self.model._meta.app_label,
+ self.model._meta.model_name
+ )
+
+ def _get_url(self, action, instance=None):
+ """
+ Return the URL name for a specific action. An instance must be specified for
+ get/edit/delete views.
+ """
+ url_format = self._get_base_url()
+
+ if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
+ return reverse(url_format.format(action))
+
+ elif action in ('get', 'edit', 'delete'):
+ if instance is None:
+ raise Exception("Resolving {} URL requires specifying an instance".format(action))
+ # Attempt to resolve using slug first
+ if hasattr(self.model, 'slug'):
+ try:
+ return reverse(url_format.format(action), kwargs={'slug': instance.slug})
+ except NoReverseMatch:
+ pass
+ return reverse(url_format.format(action), kwargs={'pk': instance.pk})
+
+ else:
+ raise Exception("Invalid action for URL resolution: {}".format(action))
+
def assertInstanceEqual(self, instance, data):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
@@ -94,108 +141,14 @@ class APITestCase(TestCase):
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
-class StandardTestCases:
+class ViewTestCases:
"""
We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them.
"""
-
- class Views(TestCase):
+ class GetObjectViewTestCase(ModelViewTestCase):
"""
- Stock TestCase suitable for testing all standard View functions:
- - List objects
- - View single object
- - Create new object
- - Modify existing object
- - Delete existing object
- - Import multiple new objects
+ Retrieve a single instance.
"""
- model = None
-
- # Data to be sent when creating/editing individual objects
- form_data = {}
-
- # CSV lines used for bulk import of new objects
- csv_data = ()
-
- # Form data used when creating multiple objects
- bulk_create_data = {}
-
- # Form data to be used when editing multiple objects at once
- bulk_edit_data = {}
-
- maxDiff = None
-
- def __init__(self, *args, **kwargs):
-
- super().__init__(*args, **kwargs)
-
- if self.model is None:
- raise Exception("Test case requires model to be defined")
-
- #
- # URL functions
- #
-
- def _get_base_url(self):
- """
- Return the base format for a URL for the test's model. Override this to test for a model which belongs
- to a different app (e.g. testing Interfaces within the virtualization app).
- """
- return '{}:{}_{{}}'.format(
- self.model._meta.app_label,
- self.model._meta.model_name
- )
-
- def _get_url(self, action, instance=None):
- """
- Return the URL name for a specific action. An instance must be specified for
- get/edit/delete views.
- """
- url_format = self._get_base_url()
-
- if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
- return reverse(url_format.format(action))
-
- elif action in ('get', 'edit', 'delete'):
- if instance is None:
- raise Exception("Resolving {} URL requires specifying an instance".format(action))
- # Attempt to resolve using slug first
- if hasattr(self.model, 'slug'):
- try:
- return reverse(url_format.format(action), kwargs={'slug': instance.slug})
- except NoReverseMatch:
- pass
- return reverse(url_format.format(action), kwargs={'pk': instance.pk})
-
- else:
- raise Exception("Invalid action for URL resolution: {}".format(action))
-
- #
- # Standard view tests
- # These methods will run by default. To disable a test, nullify its method on the subclasses TestCase:
- #
- # test_list_objects = None
- #
-
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_list_objects(self):
- # Attempt to make the request without required permissions
- with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
-
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(self._get_url('list'))
- self.assertHttpStatus(response, 200)
-
- # Built-in CSV export
- if hasattr(self.model, 'csv_headers'):
- response = self.client.get('{}?export'.format(self._get_url('list')))
- self.assertHttpStatus(response, 200)
- self.assertEqual(response.get('Content-Type'), 'text/csv')
-
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object(self):
instance = self.model.objects.first()
@@ -211,6 +164,12 @@ class StandardTestCases:
response = self.client.get(instance.get_absolute_url())
self.assertHttpStatus(response, 200)
+ class CreateObjectViewTestCase(ModelViewTestCase):
+ """
+ Create a single new instance.
+ """
+ form_data = {}
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object(self):
initial_count = self.model.objects.count()
@@ -235,6 +194,12 @@ class StandardTestCases:
instance = self.model.objects.order_by('-pk').first()
self.assertInstanceEqual(instance, self.form_data)
+ class EditObjectViewTestCase(ModelViewTestCase):
+ """
+ Edit a single existing instance.
+ """
+ form_data = {}
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object(self):
instance = self.model.objects.first()
@@ -259,6 +224,10 @@ class StandardTestCases:
instance = self.model.objects.get(pk=instance.pk)
self.assertInstanceEqual(instance, self.form_data)
+ class DeleteObjectViewTestCase(ModelViewTestCase):
+ """
+ Delete a single instance.
+ """
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object(self):
instance = self.model.objects.first()
@@ -283,6 +252,66 @@ class StandardTestCases:
with self.assertRaises(ObjectDoesNotExist):
self.model.objects.get(pk=instance.pk)
+ class ListObjectsViewTestCase(ModelViewTestCase):
+ """
+ Retrieve multiple instances.
+ """
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_list_objects(self):
+ # Attempt to make the request without required permissions
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
+
+ # Assign the required permission and submit again
+ self.add_permissions(
+ '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+ )
+ response = self.client.get(self._get_url('list'))
+ self.assertHttpStatus(response, 200)
+
+ # Built-in CSV export
+ if hasattr(self.model, 'csv_headers'):
+ response = self.client.get('{}?export'.format(self._get_url('list')))
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(response.get('Content-Type'), 'text/csv')
+
+ class BulkCreateObjectsViewTestCase(ModelViewTestCase):
+ """
+ Create multiple instances using a single form. Expects the creation of three new instances by default.
+ """
+ bulk_create_count = 3
+ bulk_create_data = {}
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_create_objects(self):
+ initial_count = self.model.objects.count()
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(self.bulk_create_data),
+ 'follow': False, # Do not follow 302 redirects
+ }
+
+ # Attempt to make the request without required permissions
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.post(**request), 403)
+
+ # Assign the required permission and submit again
+ self.add_permissions(
+ '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+ )
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 302)
+
+ self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count())
+ for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]:
+ self.assertInstanceEqual(instance, self.bulk_create_data)
+
+ class ImportObjectsViewTestCase(ModelViewTestCase):
+ """
+ Create multiple instances from imported data.
+ """
+ csv_data = ()
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_import_objects(self):
initial_count = self.model.objects.count()
@@ -307,6 +336,12 @@ class StandardTestCases:
self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+ class BulkEditObjectsViewTestCase(ModelViewTestCase):
+ """
+ Edit multiple instances.
+ """
+ bulk_edit_data = {}
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects(self):
# Bulk edit the first three objects only
@@ -338,6 +373,10 @@ class StandardTestCases:
for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
self.assertInstanceEqual(instance, self.bulk_edit_data)
+ class BulkDeleteObjectsViewTestCase(ModelViewTestCase):
+ """
+ Delete multiple instances.
+ """
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects(self):
pk_list = self.model.objects.values_list('pk', flat=True)
@@ -366,31 +405,55 @@ class StandardTestCases:
# Check that all objects were deleted
self.assertEqual(self.model.objects.count(), 0)
- #
- # Optional view tests
- # These methods will run only if the required data
- #
+ class PrimaryObjectViewTestCase(
+ GetObjectViewTestCase,
+ CreateObjectViewTestCase,
+ EditObjectViewTestCase,
+ DeleteObjectViewTestCase,
+ ListObjectsViewTestCase,
+ ImportObjectsViewTestCase,
+ BulkEditObjectsViewTestCase,
+ BulkDeleteObjectsViewTestCase,
+ ):
+ """
+ TestCase suitable for testing all standard View functions for primary objects
+ """
+ maxDiff = None
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def _test_bulk_create_objects(self, expected_count):
- initial_count = self.model.objects.count()
- request = {
- 'path': self._get_url('add'),
- 'data': post_data(self.bulk_create_data),
- 'follow': False, # Do not follow 302 redirects
- }
+ class OrganizationalObjectViewTestCase(
+ CreateObjectViewTestCase,
+ EditObjectViewTestCase,
+ ListObjectsViewTestCase,
+ ImportObjectsViewTestCase,
+ BulkDeleteObjectsViewTestCase,
+ ):
+ """
+ TestCase suitable for all organizational objects
+ """
+ maxDiff = None
- # Attempt to make the request without required permissions
- with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.post(**request), 403)
+ class DeviceComponentTemplateViewTestCase(
+ EditObjectViewTestCase,
+ DeleteObjectViewTestCase,
+ BulkCreateObjectsViewTestCase,
+ BulkEditObjectsViewTestCase,
+ BulkDeleteObjectsViewTestCase,
+ ):
+ """
+ TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.)
+ """
+ maxDiff = None
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
-
- self.assertEqual(initial_count + expected_count, self.model.objects.count())
- for instance in self.model.objects.order_by('-pk')[:expected_count]:
- self.assertInstanceEqual(instance, self.bulk_create_data)
+ class DeviceComponentViewTestCase(
+ EditObjectViewTestCase,
+ DeleteObjectViewTestCase,
+ ListObjectsViewTestCase,
+ BulkCreateObjectsViewTestCase,
+ ImportObjectsViewTestCase,
+ BulkEditObjectsViewTestCase,
+ BulkDeleteObjectsViewTestCase,
+ ):
+ """
+ TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
+ """
+ maxDiff = None
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 469b21111..38ec6e196 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -21,11 +21,13 @@ def post_data(data):
return ret
-def create_test_user(username='testuser', permissions=list()):
+def create_test_user(username='testuser', permissions=None):
"""
Create a User with the given permissions.
"""
user = User.objects.create_user(username=username)
+ if permissions is None:
+ permissions = ()
for perm_name in permissions:
app, codename = perm_name.split('.')
perm = Permission.objects.get(content_type__app_label=app, codename=codename)
diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py
new file mode 100644
index 000000000..a875c688c
--- /dev/null
+++ b/netbox/utilities/tests/test_ordering.py
@@ -0,0 +1,43 @@
+from django.test import TestCase
+
+from utilities.ordering import naturalize, naturalize_interface
+
+
+class NaturalizationTestCase(TestCase):
+ """
+ Validate the operation of the functions which generate values suitable for natural ordering.
+ """
+ def test_naturalize(self):
+
+ data = (
+ # Original, naturalized
+ ('abc', 'abc'),
+ ('123', '00000123'),
+ ('abc123', 'abc00000123'),
+ ('123abc', '00000123abc'),
+ ('123abc456', '00000123abc00000456'),
+ ('abc123def', 'abc00000123def'),
+ ('abc123def456', 'abc00000123def00000456'),
+ )
+
+ for origin, naturalized in data:
+ self.assertEqual(naturalize(origin), naturalized)
+
+ def test_naturalize_interface(self):
+
+ data = (
+ # Original, naturalized
+ ('Gi', '9999999999999999Gi000000000000000000'),
+ ('Gi1', '9999999999999999Gi000001000000000000'),
+ ('Gi1/2', '0001999999999999Gi000002000000000000'),
+ ('Gi1/2/3', '0001000299999999Gi000003000000000000'),
+ ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'),
+ ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'),
+ ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'),
+ ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
+ ('Gi1:2', '9999999999999999Gi000001000002000000'),
+ ('Gi1:2.3', '9999999999999999Gi000001000002000003'),
+ )
+
+ for origin, naturalized in data:
+ self.assertEqual(naturalize_interface(origin), naturalized)
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 8725cbee1..a294cdb6f 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
- mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
+ mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py
index 3c4a17c7b..1dae88e1d 100644
--- a/netbox/virtualization/choices.py
+++ b/netbox/virtualization/choices.py
@@ -8,14 +8,20 @@ from utilities.choices import ChoiceSet
class VirtualMachineStatusChoices(ChoiceSet):
- STATUS_ACTIVE = 'active'
STATUS_OFFLINE = 'offline'
+ STATUS_ACTIVE = 'active'
+ STATUS_PLANNED = 'planned'
STATUS_STAGED = 'staged'
+ STATUS_FAILED = 'failed'
+ STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = (
- (STATUS_ACTIVE, 'Active'),
(STATUS_OFFLINE, 'Offline'),
+ (STATUS_ACTIVE, 'Active'),
+ (STATUS_PLANNED, 'Planned'),
(STATUS_STAGED, 'Staged'),
+ (STATUS_FAILED, 'Failed'),
+ (STATUS_DECOMMISSIONING, 'Decommissioning'),
)
LEGACY_MAP = {
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 1560a683f..12393d400 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -14,9 +14,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
- ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm,
- CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2,
- StaticSelect2Multiple, TagFilterField,
+ CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+ ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -77,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm):
#
class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+ type = DynamicModelChoiceField(
+ queryset=ClusterType.objects.all(),
+ widget=APISelect(
+ api_url="/api/virtualization/cluster-types/"
+ )
+ )
+ group = DynamicModelChoiceField(
+ queryset=ClusterGroup.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url="/api/virtualization/cluster-groups/"
+ )
+ )
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url="/api/dcim/sites/"
+ )
+ )
comments = CommentField()
tags = TagField(
required=False
@@ -84,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta:
model = Cluster
- fields = [
+ fields = (
'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
- ]
- widgets = {
- 'type': APISelect(
- api_url="/api/virtualization/cluster-types/"
- ),
- 'group': APISelect(
- api_url="/api/virtualization/cluster-groups/"
- ),
- 'site': APISelect(
- api_url="/api/dcim/sites/"
- ),
- }
+ )
class ClusterCSVForm(CustomFieldModelCSVForm):
@@ -147,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
queryset=Cluster.objects.all(),
widget=forms.MultipleHiddenInput()
)
- type = forms.ModelChoiceField(
+ type = DynamicModelChoiceField(
queryset=ClusterType.objects.all(),
required=False,
widget=APISelect(
api_url="/api/virtualization/cluster-types/"
)
)
- group = forms.ModelChoiceField(
+ group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelect(
api_url="/api/virtualization/cluster-groups/"
)
)
- tenant = forms.ModelChoiceField(
+ tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
- required=False
+ required=False,
+ widget=APISelect(
+ api_url="/api/tenancy/tenants/"
+ )
)
- site = forms.ModelChoiceField(
+ site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
@@ -189,7 +200,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant'
]
q = forms.CharField(required=False, label='Search')
- type = FilterChoiceField(
+ type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='slug',
required=False,
@@ -198,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
value_field='slug',
)
)
- region = FilterChoiceField(
+ region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -210,10 +221,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
}
)
)
- site = FilterChoiceField(
+ site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
@@ -221,10 +231,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
null_option=True,
)
)
- group = FilterChoiceField(
+ group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
- null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/",
@@ -235,8 +244,8 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
tag = TagFilterField(model)
-class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
- region = forms.ModelChoiceField(
+class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
+ region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
widget=APISelect(
@@ -249,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
}
)
)
- site = ChainedModelChoiceField(
+ site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- chains=(
- ('region', 'region'),
- ),
required=False,
widget=APISelect(
api_url='/api/dcim/sites/',
@@ -263,11 +269,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
}
)
)
- rack = ChainedModelChoiceField(
+ rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
- chains=(
- ('site', 'site'),
- ),
required=False,
widget=APISelect(
api_url='/api/dcim/racks/',
@@ -279,12 +282,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
}
)
)
- devices = ChainedModelMultipleChoiceField(
+ devices = DynamicModelMultipleChoiceField(
queryset=Device.objects.filter(cluster__isnull=True),
- chains=(
- ('site', 'site'),
- ('rack', 'rack'),
- ),
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
display_field='display_name',
@@ -331,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
#
class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
- cluster_group = forms.ModelChoiceField(
+ cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelect(
@@ -344,15 +343,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
)
)
- cluster = ChainedModelChoiceField(
+ cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- chains=(
- ('group', 'cluster_group'),
- ),
widget=APISelect(
api_url='/api/virtualization/clusters/'
)
)
+ role = DynamicModelChoiceField(
+ queryset=DeviceRole.objects.all(),
+ widget=APISelect(
+ api_url="/api/dcim/device-roles/",
+ additional_query_params={
+ "vm_role": "True"
+ }
+ )
+ )
+ platform = DynamicModelChoiceField(
+ queryset=Platform.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url='/api/dcim/platforms/'
+ )
+ )
tags = TagField(
required=False
)
@@ -373,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
widgets = {
"status": StaticSelect2(),
- "role": APISelect(
- api_url="/api/dcim/device-roles/",
- additional_query_params={
- "vm_role": "True"
- }
- ),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
- 'platform': APISelect(
- api_url='/api/dcim/platforms/'
- )
}
def __init__(self, *args, **kwargs):
@@ -493,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
initial='',
widget=StaticSelect2(),
)
- cluster = forms.ModelChoiceField(
+ cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
widget=APISelect(
api_url='/api/virtualization/clusters/'
)
)
- role = forms.ModelChoiceField(
+ role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
),
@@ -512,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
}
)
)
- tenant = forms.ModelChoiceField(
+ tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
api_url='/api/tenancy/tenants/'
)
)
- platform = forms.ModelChoiceField(
+ platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False,
widget=APISelect(
@@ -559,34 +562,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False,
label='Search'
)
- cluster_group = FilterChoiceField(
+ cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url='/api/virtualization/cluster-groups/',
value_field="slug",
null_option=True,
)
)
- cluster_type = FilterChoiceField(
+ cluster_type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url='/api/virtualization/cluster-types/',
value_field="slug",
null_option=True,
)
)
- cluster_id = FilterChoiceField(
+ cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
+ required=False,
label='Cluster',
widget=APISelectMultiple(
api_url='/api/virtualization/clusters/',
)
)
- region = FilterChoiceField(
+ region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -598,20 +602,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
}
)
)
- site = FilterChoiceField(
+ site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url='/api/dcim/sites/',
value_field="slug",
null_option=True,
)
)
- role = FilterChoiceField(
+ role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.filter(vm_role=True),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url='/api/dcim/device-roles/',
value_field="slug",
@@ -626,10 +630,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False,
widget=StaticSelect2Multiple()
)
- platform = FilterChoiceField(
+ platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug',
- null_label='-- None --',
+ required=False,
widget=APISelectMultiple(
api_url='/api/dcim/platforms/',
value_field="slug",
@@ -648,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
#
class InterfaceForm(BootstrapMixin, forms.ModelForm):
- untagged_vlan = forms.ModelChoiceField(
+ untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
@@ -657,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
full=True
)
)
- tagged_vlans = forms.ModelMultipleChoiceField(
+ tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
@@ -774,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
required=False,
widget=StaticSelect2(),
)
- untagged_vlan = forms.ModelChoiceField(
+ untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
@@ -783,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
full=True
)
)
- tagged_vlans = forms.ModelMultipleChoiceField(
+ tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
@@ -862,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
required=False,
widget=StaticSelect2()
)
- untagged_vlan = forms.ModelChoiceField(
+ untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
@@ -871,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
full=True
)
)
- tagged_vlans = forms.ModelMultipleChoiceField(
+ tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 3ec5ccf8e..13b181137 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -267,9 +267,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
]
STATUS_CLASS_MAP = {
- 'active': 'success',
- 'offline': 'warning',
- 'staged': 'primary',
+ VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning',
+ VirtualMachineStatusChoices.STATUS_ACTIVE: 'success',
+ VirtualMachineStatusChoices.STATUS_PLANNED: 'info',
+ VirtualMachineStatusChoices.STATUS_STAGED: 'primary',
+ VirtualMachineStatusChoices.STATUS_FAILED: 'danger',
+ VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning',
}
class Meta:
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 6cedf9803..639908977 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -3,19 +3,14 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Interface, Platform, Site
from ipam.models import VLAN
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
-class ClusterGroupTestCase(StandardTestCases.Views):
+class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = ClusterGroup
- # Disable inapplicable tests
- test_get_object = None
- test_delete_object = None
- test_bulk_edit_objects = None
-
@classmethod
def setUpTestData(cls):
@@ -38,14 +33,9 @@ class ClusterGroupTestCase(StandardTestCases.Views):
)
-class ClusterTypeTestCase(StandardTestCases.Views):
+class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = ClusterType
- # Disable inapplicable tests
- test_get_object = None
- test_delete_object = None
- test_bulk_edit_objects = None
-
@classmethod
def setUpTestData(cls):
@@ -68,7 +58,7 @@ class ClusterTypeTestCase(StandardTestCases.Views):
)
-class ClusterTestCase(StandardTestCases.Views):
+class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Cluster
@classmethod
@@ -124,7 +114,7 @@ class ClusterTestCase(StandardTestCases.Views):
}
-class VirtualMachineTestCase(StandardTestCases.Views):
+class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualMachine
@classmethod
@@ -193,17 +183,16 @@ class VirtualMachineTestCase(StandardTestCases.Views):
}
-class InterfaceTestCase(StandardTestCases.Views):
+class InterfaceTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.DeviceComponentViewTestCase,
+):
model = Interface
# Disable inapplicable tests
test_list_objects = None
- test_create_object = None
test_import_objects = None
- def test_bulk_create_objects(self):
- return self._test_bulk_create_objects(expected_count=3)
-
def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}'