Преглед изворни кода

Merge pull request #4072 from netbox-community/4000-view-tests

Closes #4000: Add tests for the create, edit, and delete views of all models
Jeremy Stretch пре 6 година
родитељ
комит
ce081a6e15

+ 47 - 93
netbox/circuits/tests/test_views.py

@@ -1,23 +1,15 @@
-import urllib.parse
-
-from django.test import Client, TestCase
-from django.urls import reverse
+import datetime
 
+from circuits.choices import *
 from circuits.models import Circuit, CircuitType, Provider
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 
 
-class ProviderTestCase(TestCase):
+class ProviderTestCase(StandardTestCases.Views):
+    model = Provider
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_provider',
-                'circuits.add_provider',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         Provider.objects.bulk_create([
             Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -25,48 +17,35 @@ class ProviderTestCase(TestCase):
             Provider(name='Provider 3', slug='provider-3', asn=65003),
         ])
 
-    def test_provider_list(self):
-
-        url = reverse('circuits:provider_list')
-        params = {
-            "q": "test",
+        cls.form_data = {
+            'name': 'Provider X',
+            'slug': 'provider-x',
+            'asn': 65123,
+            'account': '1234',
+            'portal_url': 'http://example.com/portal',
+            'noc_contact': 'noc@example.com',
+            'admin_contact': 'admin@example.com',
+            'comments': 'Another provider',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_provider(self):
-
-        provider = Provider.objects.first()
-        response = self.client.get(provider.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_provider_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Provider 4,provider-4",
             "Provider 5,provider-5",
             "Provider 6,provider-6",
         )
 
-        response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Provider.objects.count(), 6)
 
+class CircuitTypeTestCase(StandardTestCases.Views):
+    model = CircuitType
 
-class CircuitTypeTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_circuittype',
-                'circuits.add_circuittype',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         CircuitType.objects.bulk_create([
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -74,39 +53,25 @@ class CircuitTypeTestCase(TestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
         ])
 
-    def test_circuittype_list(self):
-
-        url = reverse('circuits:circuittype_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuittype_import(self):
+        cls.form_data = {
+            'name': 'Circuit Type X',
+            'slug': 'circuit-type-x',
+            'description': 'A new circuit type',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Circuit Type 4,circuit-type-4",
             "Circuit Type 5,circuit-type-5",
             "Circuit Type 6,circuit-type-6",
         )
 
-        response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(CircuitType.objects.count(), 6)
-
 
-class CircuitTestCase(TestCase):
+class CircuitTestCase(StandardTestCases.Views):
+    model = Circuit
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_circuit',
-                'circuits.add_circuit',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider.save()
@@ -120,33 +85,22 @@ class CircuitTestCase(TestCase):
             Circuit(cid='Circuit 3', provider=provider, type=circuittype),
         ])
 
-    def test_circuit_list(self):
-
-        url = reverse('circuits:circuit_list')
-        params = {
-            "provider": Provider.objects.first().slug,
-            "type": CircuitType.objects.first().slug,
+        cls.form_data = {
+            'cid': 'Circuit X',
+            'provider': provider.pk,
+            'type': circuittype.pk,
+            'status': CircuitStatusChoices.STATUS_ACTIVE,
+            'tenant': None,
+            'install_date': datetime.date(2020, 1, 1),
+            'commit_rate': 1000,
+            'description': 'A new circuit',
+            'comments': 'Some comments',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuit(self):
-
-        circuit = Circuit.objects.first()
-        response = self.client.get(circuit.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuit_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "cid,provider,type",
             "Circuit 4,Provider 1,Circuit Type 1",
             "Circuit 5,Provider 1,Circuit Type 1",
             "Circuit 6,Provider 1,Circuit Type 1",
         )
-
-        response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Circuit.objects.count(), 6)

+ 1 - 0
netbox/dcim/forms.py

@@ -1549,6 +1549,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     )
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
+        required=False,
         widget=APISelect(
             api_url="/api/dcim/manufacturers/",
             filter_for={

Разлика између датотеке није приказан због своје велике величине
+ 296 - 376
netbox/dcim/tests/test_views.py


+ 51 - 43
netbox/extras/tests/test_views.py

@@ -2,48 +2,55 @@ import urllib.parse
 import uuid
 
 from django.contrib.auth.models import User
-from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Site
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ConfigContext, ObjectChange, Tag
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases, TestCase
 
 
-class TagTestCase(TestCase):
+class TagTestCase(StandardTestCases.Views):
+    model = Tag
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_tag'])
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_create_object = None
+    test_import_objects = None
 
-        Tag.objects.bulk_create([
+    # TODO: Restore test when #4071 is resolved
+    test_get_object = None
+
+    @classmethod
+    def setUpTestData(cls):
+
+        Tag.objects.bulk_create((
             Tag(name='Tag 1', slug='tag-1'),
             Tag(name='Tag 2', slug='tag-2'),
             Tag(name='Tag 3', slug='tag-3'),
-        ])
+        ))
 
-    def test_tag_list(self):
-
-        url = reverse('extras:tag_list')
-        params = {
-            "q": "tag",
+        cls.form_data = {
+            'name': 'Tag X',
+            'slug': 'tag-x',
+            'color': 'c0c0c0',
+            'comments': 'Some comments',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
 
+class ConfigContextTestCase(StandardTestCases.Views):
+    model = ConfigContext
 
-class ConfigContextTestCase(TestCase):
+    # Disable inapplicable tests
+    test_import_objects = None
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_configcontext'])
-        self.client = Client()
-        self.client.force_login(user)
+    # TODO: Resolve model discrepancies when creating/editing ConfigContexts
+    test_create_object = None
+    test_edit_object = None
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
 
         # Create three ConfigContexts
         for i in range(1, 4):
@@ -54,34 +61,35 @@ class ConfigContextTestCase(TestCase):
             configcontext.save()
             configcontext.sites.add(site)
 
-    def test_configcontext_list(self):
-
-        url = reverse('extras:configcontext_list')
-        params = {
-            "q": "foo",
+        cls.form_data = {
+            'name': 'Config Context X',
+            'weight': 200,
+            'description': 'A new config context',
+            'is_active': True,
+            'regions': [],
+            'sites': [site.pk],
+            'roles': [],
+            'platforms': [],
+            'tenant_groups': [],
+            'tenants': [],
+            'tags': [],
+            'data': '{"foo": 123}',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_configcontext(self):
-
-        configcontext = ConfigContext.objects.first()
-        response = self.client.get(configcontext.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
 
 class ObjectChangeTestCase(TestCase):
+    user_permissions = (
+        'extras.view_objectchange',
+    )
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_objectchange'])
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
 
         # Create three ObjectChanges
+        user = User.objects.create_user(username='testuser2')
         for i in range(1, 4):
             oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
             oc.user = user
@@ -96,10 +104,10 @@ class ObjectChangeTestCase(TestCase):
         }
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
 
     def test_objectchange(self):
 
         objectchange = ObjectChange.objects.first()
         response = self.client.get(objectchange.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)

+ 146 - 278
netbox/ipam/tests/test_views.py

@@ -1,26 +1,18 @@
-from netaddr import IPNetwork
-import urllib.parse
+import datetime
 
-from django.test import Client, TestCase
-from django.urls import reverse
+from netaddr import IPNetwork
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from ipam.choices import ServiceProtocolChoices
+from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 
 
-class VRFTestCase(TestCase):
+class VRFTestCase(StandardTestCases.Views):
+    model = VRF
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vrf',
-                'ipam.add_vrf',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         VRF.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
@@ -28,48 +20,32 @@ class VRFTestCase(TestCase):
             VRF(name='VRF 3', rd='65000:3'),
         ])
 
-    def test_vrf_list(self):
-
-        url = reverse('ipam:vrf_list')
-        params = {
-            "q": "65000",
+        cls.form_data = {
+            'name': 'VRF X',
+            'rd': '65000:999',
+            'tenant': None,
+            'enforce_unique': True,
+            'description': 'A new VRF',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vrf(self):
-
-        vrf = VRF.objects.first()
-        response = self.client.get(vrf.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_vrf_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name",
             "VRF 4",
             "VRF 5",
             "VRF 6",
         )
 
-        response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VRF.objects.count(), 6)
+class RIRTestCase(StandardTestCases.Views):
+    model = RIR
 
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
 
-class RIRTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_rir',
-                'ipam.add_rir',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         RIR.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
@@ -77,42 +53,27 @@ class RIRTestCase(TestCase):
             RIR(name='RIR 3', slug='rir-3'),
         ])
 
-    def test_rir_list(self):
-
-        url = reverse('ipam:rir_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_rir_import(self):
+        cls.form_data = {
+            'name': 'RIR X',
+            'slug': 'rir-x',
+            'is_private': True,
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "RIR 4,rir-4",
             "RIR 5,rir-5",
             "RIR 6,rir-6",
         )
 
-        response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(RIR.objects.count(), 6)
 
+class AggregateTestCase(StandardTestCases.Views):
+    model = Aggregate
 
-class AggregateTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_aggregate',
-                'ipam.add_aggregate',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
-        rir = RIR(name='RIR 1', slug='rir-1')
-        rir.save()
+        rir = RIR.objects.create(name='RIR 1', slug='rir-1')
 
         Aggregate.objects.bulk_create([
             Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
@@ -120,48 +81,32 @@ class AggregateTestCase(TestCase):
             Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
         ])
 
-    def test_aggregate_list(self):
-
-        url = reverse('ipam:aggregate_list')
-        params = {
-            "rir": RIR.objects.first().slug,
+        cls.form_data = {
+            'family': 4,
+            'prefix': IPNetwork('10.99.0.0/16'),
+            'rir': rir.pk,
+            'date_added': datetime.date(2020, 1, 1),
+            'description': 'A new aggregate',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_aggregate(self):
-
-        aggregate = Aggregate.objects.first()
-        response = self.client.get(aggregate.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_aggregate_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "prefix,rir",
             "10.4.0.0/16,RIR 1",
             "10.5.0.0/16,RIR 1",
             "10.6.0.0/16,RIR 1",
         )
 
-        response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Aggregate.objects.count(), 6)
 
+class RoleTestCase(StandardTestCases.Views):
+    model = Role
 
-class RoleTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_role',
-                'ipam.add_role',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         Role.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
@@ -169,42 +114,31 @@ class RoleTestCase(TestCase):
             Role(name='Role 3', slug='role-3'),
         ])
 
-    def test_role_list(self):
-
-        url = reverse('ipam:role_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_role_import(self):
+        cls.form_data = {
+            'name': 'Role X',
+            'slug': 'role-x',
+            'weight': 200,
+            'description': 'A new role',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug,weight",
             "Role 4,role-4,1000",
             "Role 5,role-5,1000",
             "Role 6,role-6,1000",
         )
 
-        response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Role.objects.count(), 6)
 
+class PrefixTestCase(StandardTestCases.Views):
+    model = Prefix
 
-class PrefixTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_prefix',
-                'ipam.add_prefix',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
-
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        vrf = VRF.objects.create(name='VRF 1', rd='65000:1')
+        role = Role.objects.create(name='Role 1', slug='role-1')
+        # vlan = VLAN.objects.create(vid=123, name='VLAN 123')
 
         Prefix.objects.bulk_create([
             Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
@@ -212,51 +146,34 @@ class PrefixTestCase(TestCase):
             Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
         ])
 
-    def test_prefix_list(self):
-
-        url = reverse('ipam:prefix_list')
-        params = {
-            "site": Site.objects.first().slug,
+        cls.form_data = {
+            'prefix': IPNetwork('192.0.2.0/24'),
+            'site': site.pk,
+            'vrf': vrf.pk,
+            'tenant': None,
+            'vlan': None,
+            'status': PrefixStatusChoices.STATUS_RESERVED,
+            'role': role.pk,
+            'is_pool': True,
+            'description': 'A new prefix',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_prefix(self):
-
-        prefix = Prefix.objects.first()
-        response = self.client.get(prefix.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_prefix_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "prefix,status",
             "10.4.0.0/16,Active",
             "10.5.0.0/16,Active",
             "10.6.0.0/16,Active",
         )
 
-        response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Prefix.objects.count(), 6)
 
+class IPAddressTestCase(StandardTestCases.Views):
+    model = IPAddress
 
-class IPAddressTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_ipaddress',
-                'ipam.add_ipaddress',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
-
-        vrf = VRF(name='VRF 1', rd='65000:1')
-        vrf.save()
+        vrf = VRF.objects.create(name='VRF 1', rd='65000:1')
 
         IPAddress.objects.bulk_create([
             IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf),
@@ -264,51 +181,38 @@ class IPAddressTestCase(TestCase):
             IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf),
         ])
 
-    def test_ipaddress_list(self):
-
-        url = reverse('ipam:ipaddress_list')
-        params = {
-            "vrf": VRF.objects.first().rd,
+        cls.form_data = {
+            'vrf': vrf.pk,
+            'address': IPNetwork('192.0.2.99/24'),
+            'tenant': None,
+            'status': IPAddressStatusChoices.STATUS_RESERVED,
+            'role': IPAddressRoleChoices.ROLE_ANYCAST,
+            'interface': None,
+            'nat_inside': None,
+            'dns_name': 'example',
+            'description': 'A new IP address',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_ipaddress(self):
-
-        ipaddress = IPAddress.objects.first()
-        response = self.client.get(ipaddress.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_ipaddress_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "address,status",
             "192.0.2.4/24,Active",
             "192.0.2.5/24,Active",
             "192.0.2.6/24,Active",
         )
 
-        response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(IPAddress.objects.count(), 6)
 
+class VLANGroupTestCase(StandardTestCases.Views):
+    model = VLANGroup
 
-class VLANGroupTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vlangroup',
-                'ipam.add_vlangroup',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
 
         VLANGroup.objects.bulk_create([
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
@@ -316,45 +220,29 @@ class VLANGroupTestCase(TestCase):
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
         ])
 
-    def test_vlangroup_list(self):
-
-        url = reverse('ipam:vlangroup_list')
-        params = {
-            "site": Site.objects.first().slug,
+        cls.form_data = {
+            'name': 'VLAN Group X',
+            'slug': 'vlan-group-x',
+            'site': site.pk,
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlangroup_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "VLAN Group 4,vlan-group-4",
             "VLAN Group 5,vlan-group-5",
             "VLAN Group 6,vlan-group-6",
         )
 
-        response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VLANGroup.objects.count(), 6)
+class VLANTestCase(StandardTestCases.Views):
+    model = VLAN
 
+    @classmethod
+    def setUpTestData(cls):
 
-class VLANTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vlan',
-                'ipam.add_vlan',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
-
-        vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
-        vlangroup.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1', site=site)
+        role = Role.objects.create(name='Role 1', slug='role-1')
 
         VLAN.objects.bulk_create([
             VLAN(group=vlangroup, vid=101, name='VLAN101'),
@@ -362,58 +250,43 @@ class VLANTestCase(TestCase):
             VLAN(group=vlangroup, vid=103, name='VLAN103'),
         ])
 
-    def test_vlan_list(self):
-
-        url = reverse('ipam:vlan_list')
-        params = {
-            "group": VLANGroup.objects.first().slug,
+        cls.form_data = {
+            'site': site.pk,
+            'group': vlangroup.pk,
+            'vid': 999,
+            'name': 'VLAN999',
+            'tenant': None,
+            'status': VLANStatusChoices.STATUS_RESERVED,
+            'role': role.pk,
+            'description': 'A new VLAN',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlan(self):
-
-        vlan = VLAN.objects.first()
-        response = self.client.get(vlan.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlan_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "vid,name,status",
             "104,VLAN104,Active",
             "105,VLAN105,Active",
             "106,VLAN106,Active",
         )
 
-        response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VLAN.objects.count(), 6)
+class ServiceTestCase(StandardTestCases.Views):
+    model = Service
 
+    # Disable inapplicable tests
+    test_import_objects = None
 
-class ServiceTestCase(TestCase):
+    # TODO: Resolve URL for Service creation
+    test_create_object = None
 
-    def setUp(self):
-        user = create_test_user(permissions=['ipam.view_service'])
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
-
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.save()
-
-        devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
-        devicetype.save()
-
-        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
-        devicerole.save()
-
-        device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
 
         Service.objects.bulk_create([
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
@@ -421,18 +294,13 @@ class ServiceTestCase(TestCase):
             Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
         ])
 
-    def test_service_list(self):
-
-        url = reverse('ipam:service_list')
-        params = {
-            "device_id": Device.objects.first(),
+        cls.form_data = {
+            'device': device.pk,
+            'virtual_machine': None,
+            'name': 'Service X',
+            'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
+            'port': 999,
+            'ipaddresses': [],
+            'description': 'A new service',
+            'tags': 'Alpha,Bravo,Charlie',
         }
-
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_service(self):
-
-        service = Service.objects.first()
-        response = self.client.get(service.get_absolute_url())
-        self.assertEqual(response.status_code, 200)

+ 3 - 3
netbox/netbox/tests/test_views.py

@@ -1,6 +1,6 @@
 import urllib.parse
 
-from django.test import TestCase
+from utilities.testing import TestCase
 from django.urls import reverse
 
 
@@ -11,7 +11,7 @@ class HomeViewTestCase(TestCase):
         url = reverse('home')
 
         response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
 
     def test_search(self):
 
@@ -21,4 +21,4 @@ class HomeViewTestCase(TestCase):
         }
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)

+ 46 - 73
netbox/secrets/tests/test_views.py

@@ -1,26 +1,22 @@
 import base64
-import urllib.parse
 
-from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
-class SecretRoleTestCase(TestCase):
+class SecretRoleTestCase(StandardTestCases.Views):
+    model = SecretRole
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'secrets.view_secretrole',
-                'secrets.add_secretrole',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+
+    @classmethod
+    def setUpTestData(cls):
 
         SecretRole.objects.bulk_create([
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -28,65 +24,40 @@ class SecretRoleTestCase(TestCase):
             SecretRole(name='Secret Role 3', slug='secret-role-3'),
         ])
 
-    def test_secretrole_list(self):
-
-        url = reverse('secrets:secretrole_list')
-
-        response = self.client.get(url, follow=True)
-        self.assertEqual(response.status_code, 200)
-
-    def test_secretrole_import(self):
+        cls.form_data = {
+            'name': 'Secret Role X',
+            'slug': 'secret-role-x',
+            'description': 'A secret role',
+            'users': [],
+            'groups': [],
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Secret Role 4,secret-role-4",
             "Secret Role 5,secret-role-5",
             "Secret Role 6,secret-role-6",
         )
 
-        response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(SecretRole.objects.count(), 6)
+class SecretTestCase(StandardTestCases.Views):
+    model = Secret
 
+    # Disable inapplicable tests
+    test_create_object = None
 
-class SecretTestCase(TestCase):
+    # TODO: Check permissions enforcement on secrets.views.secret_edit
+    test_edit_object = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'secrets.view_secret',
-                'secrets.add_secret',
-            ]
-        )
+    @classmethod
+    def setUpTestData(cls):
 
-        # Set up a master key
-        userkey = UserKey(user=user, public_key=PUBLIC_KEY)
-        userkey.save()
-        master_key = userkey.get_master_key(PRIVATE_KEY)
-        self.session_key = SessionKey(userkey=userkey)
-        self.session_key.save(master_key)
-
-        self.client = Client()
-        self.client.force_login(user)
-
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
-
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.save()
-
-        devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
-        devicetype.save()
-
-        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
-        devicerole.save()
-
-        device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device.save()
-
-        secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1')
-        secretrole.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
+        secretrole = SecretRole.objects.create(name='Secret Role 1', slug='secret-role-1')
 
         Secret.objects.bulk_create([
             Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
@@ -94,23 +65,25 @@ class SecretTestCase(TestCase):
             Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'),
         ])
 
-    def test_secret_list(self):
-
-        url = reverse('secrets:secret_list')
-        params = {
-            "role": SecretRole.objects.first().slug,
+        cls.form_data = {
+            'device': device.pk,
+            'role': secretrole.pk,
+            'name': 'Secret X',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
-        self.assertEqual(response.status_code, 200)
+    def setUp(self):
 
-    def test_secret(self):
+        super().setUp()
 
-        secret = Secret.objects.first()
-        response = self.client.get(secret.get_absolute_url(), follow=True)
-        self.assertEqual(response.status_code, 200)
+        # Set up a master key for the test user
+        userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
+        userkey.save()
+        master_key = userkey.get_master_key(PRIVATE_KEY)
+        self.session_key = SessionKey(userkey=userkey)
+        self.session_key.save(master_key)
 
-    def test_secret_import(self):
+    def test_import_objects(self):
+        self.add_permissions('secrets.add_secret')
 
         csv_data = (
             "device,role,name,plaintext",
@@ -125,5 +98,5 @@ class SecretTestCase(TestCase):
 
         response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
         self.assertEqual(Secret.objects.count(), 6)

+ 27 - 64
netbox/tenancy/tests/test_views.py

@@ -1,23 +1,16 @@
-import urllib.parse
-
-from django.test import Client, TestCase
-from django.urls import reverse
-
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 
 
-class TenantGroupTestCase(TestCase):
+class TenantGroupTestCase(StandardTestCases.Views):
+    model = TenantGroup
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'tenancy.view_tenantgroup',
-                'tenancy.add_tenantgroup',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+
+    @classmethod
+    def setUpTestData(cls):
 
         TenantGroup.objects.bulk_create([
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
@@ -25,42 +18,26 @@ class TenantGroupTestCase(TestCase):
             TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
         ])
 
-    def test_tenantgroup_list(self):
-
-        url = reverse('tenancy:tenantgroup_list')
-
-        response = self.client.get(url, follow=True)
-        self.assertEqual(response.status_code, 200)
-
-    def test_tenantgroup_import(self):
+        cls.form_data = {
+            'name': 'Tenant Group X',
+            'slug': 'tenant-group-x',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Tenant Group 4,tenant-group-4",
             "Tenant Group 5,tenant-group-5",
             "Tenant Group 6,tenant-group-6",
         )
 
-        response = self.client.post(reverse('tenancy:tenantgroup_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(TenantGroup.objects.count(), 6)
 
+class TenantTestCase(StandardTestCases.Views):
+    model = Tenant
 
-class TenantTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'tenancy.view_tenant',
-                'tenancy.add_tenant',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
-
-        tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1')
-        tenantgroup.save()
+        tenantgroup = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
 
         Tenant.objects.bulk_create([
             Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroup),
@@ -68,32 +45,18 @@ class TenantTestCase(TestCase):
             Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroup),
         ])
 
-    def test_tenant_list(self):
-
-        url = reverse('tenancy:tenant_list')
-        params = {
-            "group": TenantGroup.objects.first().slug,
+        cls.form_data = {
+            'name': 'Tenant X',
+            'slug': 'tenant-x',
+            'group': tenantgroup.pk,
+            'description': 'A new tenant',
+            'comments': 'Some comments',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
-        self.assertEqual(response.status_code, 200)
-
-    def test_tenant(self):
-
-        tenant = Tenant.objects.first()
-        response = self.client.get(tenant.get_absolute_url(), follow=True)
-        self.assertEqual(response.status_code, 200)
-
-    def test_tenant_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Tenant 4,tenant-4",
             "Tenant 5,tenant-5",
             "Tenant 6,tenant-6",
         )
-
-        response = self.client.post(reverse('tenancy:tenant_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Tenant.objects.count(), 6)

+ 0 - 79
netbox/utilities/testing.py

@@ -1,79 +0,0 @@
-import logging
-from contextlib import contextmanager
-
-from django.contrib.auth.models import Permission, 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, getattr(response, 'data', 'No data')
-        ))
-
-
-def create_test_user(username='testuser', permissions=list()):
-    """
-    Create a User with the given permissions.
-    """
-    user = User.objects.create_user(username=username)
-    for perm_name in permissions:
-        app, codename = perm_name.split('.')
-        perm = Permission.objects.get(content_type__app_label=app, codename=codename)
-        user.user_permissions.add(perm)
-
-    return user
-
-
-def choices_to_dict(choices_list):
-    """
-    Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example:
-
-        [
-            {
-                "value": "choice-1",
-                "label": "First Choice"
-            },
-            {
-                "value": "choice-2",
-                "label": "Second Choice"
-            }
-        ]
-
-    Becomes:
-
-        {
-            "choice-1": "First Choice",
-            "choice-2": "Second Choice
-        }
-    """
-    return {
-        choice['value']: choice['label'] for choice in choices_list
-    }
-
-
-@contextmanager
-def disable_warnings(logger_name):
-    """
-    Temporarily suppress expected warning messages to keep the test output clean.
-    """
-    logger = logging.getLogger(logger_name)
-    current_level = logger.level
-    logger.setLevel(logging.ERROR)
-    yield
-    logger.setLevel(current_level)

+ 2 - 0
netbox/utilities/testing/__init__.py

@@ -0,0 +1,2 @@
+from .testcases import *
+from .utils import *

+ 249 - 0
netbox/utilities/testing/testcases.py

@@ -0,0 +1,249 @@
+from django.contrib.auth.models import Permission, User
+from django.core.exceptions import ObjectDoesNotExist
+from django.test import Client, TestCase as _TestCase, override_settings
+from django.urls import reverse, NoReverseMatch
+from rest_framework.test import APIClient
+
+from users.models import Token
+from .utils import disable_warnings, model_to_dict, post_data
+
+
+class TestCase(_TestCase):
+    user_permissions = ()
+
+    def setUp(self):
+
+        # Create the test user and assign permissions
+        self.user = User.objects.create_user(username='testuser')
+        self.add_permissions(*self.user_permissions)
+
+        # Initialize the test client
+        self.client = Client()
+        self.client.force_login(self.user)
+
+    #
+    # Permissions management
+    #
+
+    def add_permissions(self, *names):
+        """
+        Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
+        """
+        for name in names:
+            app, codename = name.split('.')
+            perm = Permission.objects.get(content_type__app_label=app, codename=codename)
+            self.user.user_permissions.add(perm)
+
+    def remove_permissions(self, *names):
+        """
+        Remove a set of permissions from the test user, if assigned.
+        """
+        for name in names:
+            app, codename = name.split('.')
+            perm = Permission.objects.get(content_type__app_label=app, codename=codename)
+            self.user.user_permissions.remove(perm)
+
+    #
+    # Convenience methods
+    #
+
+    def assertHttpStatus(self, response, expected_status):
+        """
+        TestCase method. 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, getattr(response, 'data', 'No data')
+        ))
+
+
+class APITestCase(TestCase):
+    client_class = APIClient
+
+    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)}
+
+
+class StandardTestCases:
+    """
+    We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them.
+    """
+
+    class Views(TestCase):
+        """
+        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
+        """
+        model = None
+        form_data = {}
+        csv_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")
+
+        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 = '{}:{}_{{}}'.format(
+                self.model._meta.app_label,
+                self.model._meta.model_name
+            )
+
+            if action in ('list', 'add', 'import'):
+                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))
+
+        @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)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_get_object(self):
+            instance = self.model.objects.first()
+
+            # Attempt to make the request without required permissions
+            with disable_warnings('django.request'):
+                self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 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(instance.get_absolute_url())
+            self.assertHttpStatus(response, 200)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_create_object(self):
+            initial_count = self.model.objects.count()
+            request = {
+                'path': self._get_url('add'),
+                'data': post_data(self.form_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 + 1, self.model.objects.count())
+            instance = self.model.objects.order_by('-pk').first()
+            self.assertDictEqual(model_to_dict(instance), self.form_data)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_edit_object(self):
+            instance = self.model.objects.first()
+
+            request = {
+                'path': self._get_url('edit', instance),
+                'data': post_data(self.form_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(
+                '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            self.assertHttpStatus(response, 302)
+
+            instance = self.model.objects.get(pk=instance.pk)
+            self.assertDictEqual(model_to_dict(instance), self.form_data)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_delete_object(self):
+            instance = self.model.objects.first()
+
+            request = {
+                'path': self._get_url('delete', instance),
+                'data': {'confirm': True},
+                '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(
+                '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            self.assertHttpStatus(response, 302)
+
+            with self.assertRaises(ObjectDoesNotExist):
+                self.model.objects.get(pk=instance.pk)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_import_objects(self):
+            initial_count = self.model.objects.count()
+            request = {
+                'path': self._get_url('import'),
+                'data': {
+                    'csv': '\n'.join(self.csv_data)
+                }
+            }
+
+            # 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(
+                '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name),
+                '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            self.assertHttpStatus(response, 200)
+
+            self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)

+ 102 - 0
netbox/utilities/testing/utils.py

@@ -0,0 +1,102 @@
+import logging
+from contextlib import contextmanager
+
+from django.contrib.auth.models import Permission, User
+from django.forms.models import model_to_dict as _model_to_dict
+
+
+def model_to_dict(instance, fields=None, exclude=None):
+    """
+    Customized wrapper for Django's built-in model_to_dict(). Does the following:
+      - Excludes the instance ID field
+      - Exclude any fields prepended with an underscore
+      - Convert any assigned tags to a comma-separated string
+    """
+    _exclude = ['id']
+    if exclude is not None:
+        _exclude += exclude
+
+    model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude)
+
+    for key in list(model_dict.keys()):
+        if key.startswith('_'):
+            del model_dict[key]
+
+        # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
+        elif key == 'tags':
+            model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
+
+        # Convert ManyToManyField to list of instance PKs
+        elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
+            model_dict[key] = [obj.pk for obj in model_dict[key]]
+
+    return model_dict
+
+
+def post_data(data):
+    """
+    Take a dictionary of test data (suitable for comparison to an instance) and return a dict suitable for POSTing.
+    """
+    ret = {}
+
+    for key, value in data.items():
+        if value is None:
+            ret[key] = ''
+        elif type(value) in (list, tuple):
+            ret[key] = value
+        else:
+            ret[key] = str(value)
+
+    return ret
+
+
+def create_test_user(username='testuser', permissions=list()):
+    """
+    Create a User with the given permissions.
+    """
+    user = User.objects.create_user(username=username)
+    for perm_name in permissions:
+        app, codename = perm_name.split('.')
+        perm = Permission.objects.get(content_type__app_label=app, codename=codename)
+        user.user_permissions.add(perm)
+
+    return user
+
+
+def choices_to_dict(choices_list):
+    """
+    Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example:
+
+        [
+            {
+                "value": "choice-1",
+                "label": "First Choice"
+            },
+            {
+                "value": "choice-2",
+                "label": "Second Choice"
+            }
+        ]
+
+    Becomes:
+
+        {
+            "choice-1": "First Choice",
+            "choice-2": "Second Choice
+        }
+    """
+    return {
+        choice['value']: choice['label'] for choice in choices_list
+    }
+
+
+@contextmanager
+def disable_warnings(logger_name):
+    """
+    Temporarily suppress expected warning messages to keep the test output clean.
+    """
+    logger = logging.getLogger(logger_name)
+    current_level = logger.level
+    logger.setLevel(logging.ERROR)
+    yield
+    logger.setLevel(current_level)

+ 68 - 128
netbox/virtualization/tests/test_views.py

@@ -1,23 +1,18 @@
-import urllib.parse
-
-from django.test import Client, TestCase
-from django.urls import reverse
-
-from utilities.testing import create_test_user
+from dcim.models import DeviceRole, Platform, Site
+from utilities.testing import StandardTestCases
+from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
-class ClusterGroupTestCase(TestCase):
+class ClusterGroupTestCase(StandardTestCases.Views):
+    model = ClusterGroup
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'virtualization.view_clustergroup',
-                'virtualization.add_clustergroup',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+
+    @classmethod
+    def setUpTestData(cls):
 
         ClusterGroup.objects.bulk_create([
             ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
@@ -25,39 +20,28 @@ class ClusterGroupTestCase(TestCase):
             ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
         ])
 
-    def test_clustergroup_list(self):
-
-        url = reverse('virtualization:clustergroup_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_clustergroup_import(self):
+        cls.form_data = {
+            'name': 'Cluster Group X',
+            'slug': 'cluster-group-x',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Cluster Group 4,cluster-group-4",
             "Cluster Group 5,cluster-group-5",
             "Cluster Group 6,cluster-group-6",
         )
 
-        response = self.client.post(reverse('virtualization:clustergroup_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(ClusterGroup.objects.count(), 6)
+class ClusterTypeTestCase(StandardTestCases.Views):
+    model = ClusterType
 
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
 
-class ClusterTypeTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'virtualization.view_clustertype',
-                'virtualization.add_clustertype',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         ClusterType.objects.bulk_create([
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@@ -65,45 +49,28 @@ class ClusterTypeTestCase(TestCase):
             ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
         ])
 
-    def test_clustertype_list(self):
-
-        url = reverse('virtualization:clustertype_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_clustertype_import(self):
+        cls.form_data = {
+            'name': 'Cluster Type X',
+            'slug': 'cluster-type-x',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Cluster Type 4,cluster-type-4",
             "Cluster Type 5,cluster-type-5",
             "Cluster Type 6,cluster-type-6",
         )
 
-        response = self.client.post(reverse('virtualization:clustertype_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(ClusterType.objects.count(), 6)
-
-
-class ClusterTestCase(TestCase):
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'virtualization.view_cluster',
-                'virtualization.add_cluster',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+class ClusterTestCase(StandardTestCases.Views):
+    model = Cluster
 
-        clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
-        clustergroup.save()
+    @classmethod
+    def setUpTestData(cls):
 
-        clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
-        clustertype.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
+        clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
 
         Cluster.objects.bulk_create([
             Cluster(name='Cluster 1', group=clustergroup, type=clustertype),
@@ -111,55 +78,34 @@ class ClusterTestCase(TestCase):
             Cluster(name='Cluster 3', group=clustergroup, type=clustertype),
         ])
 
-    def test_cluster_list(self):
-
-        url = reverse('virtualization:cluster_list')
-        params = {
-            "group": ClusterGroup.objects.first().slug,
-            "type": ClusterType.objects.first().slug,
+        cls.form_data = {
+            'name': 'Cluster X',
+            'group': clustergroup.pk,
+            'type': clustertype.pk,
+            'tenant': None,
+            'site': site.pk,
+            'comments': 'Some comments',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_cluster(self):
-
-        cluster = Cluster.objects.first()
-        response = self.client.get(cluster.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_cluster_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,type",
             "Cluster 4,Cluster Type 1",
             "Cluster 5,Cluster Type 1",
             "Cluster 6,Cluster Type 1",
         )
 
-        response = self.client.post(reverse('virtualization:cluster_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Cluster.objects.count(), 6)
 
+class VirtualMachineTestCase(StandardTestCases.Views):
+    model = VirtualMachine
 
-class VirtualMachineTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'virtualization.view_virtualmachine',
-                'virtualization.add_virtualmachine',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
-        clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
-        clustertype.save()
-
-        cluster = Cluster(name='Cluster 1', type=clustertype)
-        cluster.save()
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        platform = Platform.objects.create(name='Platform 1', slug='platform-1')
+        clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster = Cluster.objects.create(name='Cluster 1', type=clustertype)
 
         VirtualMachine.objects.bulk_create([
             VirtualMachine(name='Virtual Machine 1', cluster=cluster),
@@ -167,32 +113,26 @@ class VirtualMachineTestCase(TestCase):
             VirtualMachine(name='Virtual Machine 3', cluster=cluster),
         ])
 
-    def test_virtualmachine_list(self):
-
-        url = reverse('virtualization:virtualmachine_list')
-        params = {
-            "cluster_id": Cluster.objects.first().pk,
+        cls.form_data = {
+            'cluster': cluster.pk,
+            'tenant': None,
+            'platform': None,
+            'name': 'Virtual Machine X',
+            'status': VirtualMachineStatusChoices.STATUS_STAGED,
+            'role': devicerole.pk,
+            'primary_ip4': None,
+            'primary_ip6': None,
+            'vcpus': 4,
+            'memory': 32768,
+            'disk': 4000,
+            'comments': 'Some comments',
+            'tags': 'Alpha,Bravo,Charlie',
+            'local_context_data': None,
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_virtualmachine(self):
-
-        virtualmachine = VirtualMachine.objects.first()
-        response = self.client.get(virtualmachine.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_virtualmachine_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,cluster",
             "Virtual Machine 4,Cluster 1",
             "Virtual Machine 5,Cluster 1",
             "Virtual Machine 6,Cluster 1",
         )
-
-        response = self.client.post(reverse('virtualization:virtualmachine_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VirtualMachine.objects.count(), 6)

Неке датотеке нису приказане због велике количине промена