Răsfoiți Sursa

Merge pull request #2922 from digitalocean/develop

Release v2.5.7
Jeremy Stretch 7 ani în urmă
părinte
comite
ac1e4b8e8f

+ 26 - 0
CHANGELOG.md

@@ -1,3 +1,29 @@
+v2.5.7 (2019-02-21)
+
+## Enhancements
+
+* [#2357](https://github.com/digitalocean/netbox/issues/2357) - Enable filtering of devices by rack face
+* [#2638](https://github.com/digitalocean/netbox/issues/2638) - Add button to copy unlocked secret to clipboard
+* [#2870](https://github.com/digitalocean/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields
+* [#2878](https://github.com/digitalocean/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber
+* [#2890](https://github.com/digitalocean/netbox/issues/2890) - Add port types for APC fiber
+* [#2898](https://github.com/digitalocean/netbox/issues/2898) - Enable filtering cables list by connection status
+* [#2903](https://github.com/digitalocean/netbox/issues/2903) - Clarify purpose of tags field on interface edit form
+
+## Bug Fixes
+
+* [#2852](https://github.com/digitalocean/netbox/issues/2852) - Allow filtering devices by null rack position
+* [#2884](https://github.com/digitalocean/netbox/issues/2884) - Don't display connect button for wireless interfaces
+* [#2888](https://github.com/digitalocean/netbox/issues/2888) - Correct foreground color of device roles in rack elevations
+* [#2893](https://github.com/digitalocean/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view
+* [#2895](https://github.com/digitalocean/netbox/issues/2895) - Fix filtering of nullable character fields
+* [#2901](https://github.com/digitalocean/netbox/issues/2901) - Fix ordering regions by site count
+* [#2910](https://github.com/digitalocean/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements
+* [#2912](https://github.com/digitalocean/netbox/issues/2912) - Cable type in filter form should be blank by default
+* [#2913](https://github.com/digitalocean/netbox/issues/2913) - Fix assigned prefixes link on VRF view
+* [#2914](https://github.com/digitalocean/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list
+* [#2915](https://github.com/digitalocean/netbox/issues/2915) - Fix bulk editing of pass-through ports
+
 v2.5.6 (2019-02-13)
 v2.5.6 (2019-02-13)
 
 
 ## Enhancements
 ## Enhancements

+ 1 - 1
docs/additional-features/reports.md

@@ -128,4 +128,4 @@ Reports can be run on the CLI by invoking the management command:
 python3 manage.py runreport <module>
 python3 manage.py runreport <module>
 ```
 ```
 
 
-One or more report modules may be specified.
+where ``<module>`` is the name of the python file in the ``reports`` directory without the ``.py`` extension.  One or more report modules may be specified.

+ 91 - 0
netbox/circuits/tests/test_views.py

@@ -0,0 +1,91 @@
+import urllib.parse
+
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from circuits.models import Circuit, CircuitType, Provider
+
+
+class ProviderTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        Provider.objects.bulk_create([
+            Provider(name='Provider 1', slug='provider-1', asn=65001),
+            Provider(name='Provider 2', slug='provider-2', asn=65002),
+            Provider(name='Provider 3', slug='provider-3', asn=65003),
+        ])
+
+    def test_provider_list(self):
+
+        url = reverse('circuits:provider_list')
+        params = {
+            "q": "test",
+        }
+
+        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)
+
+
+class CircuitTypeTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        CircuitType.objects.bulk_create([
+            CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
+            CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
+            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)
+
+
+class CircuitTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
+        provider.save()
+
+        circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
+        circuittype.save()
+
+        Circuit.objects.bulk_create([
+            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
+            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,
+        }
+
+        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)

+ 18 - 0
netbox/dcim/constants.py

@@ -43,6 +43,12 @@ RACK_STATUS_CHOICES = [
     [RACK_STATUS_DEPRECATED, 'Deprecated'],
     [RACK_STATUS_DEPRECATED, 'Deprecated'],
 ]
 ]
 
 
+# Device rack position
+DEVICE_POSITION_CHOICES = [
+    # Rack.u_height is limited to 100
+    (i, 'Unit {}'.format(i)) for i in range(1, 101)
+]
+
 # Parent/child device roles
 # Parent/child device roles
 SUBDEVICE_ROLE_PARENT = True
 SUBDEVICE_ROLE_PARENT = True
 SUBDEVICE_ROLE_CHILD = False
 SUBDEVICE_ROLE_CHILD = False
@@ -270,11 +276,14 @@ PORT_TYPE_8P8C = 1000
 PORT_TYPE_110_PUNCH = 1100
 PORT_TYPE_110_PUNCH = 1100
 PORT_TYPE_ST = 2000
 PORT_TYPE_ST = 2000
 PORT_TYPE_SC = 2100
 PORT_TYPE_SC = 2100
+PORT_TYPE_SC_APC = 2110
 PORT_TYPE_FC = 2200
 PORT_TYPE_FC = 2200
 PORT_TYPE_LC = 2300
 PORT_TYPE_LC = 2300
+PORT_TYPE_LC_APC = 2310
 PORT_TYPE_MTRJ = 2400
 PORT_TYPE_MTRJ = 2400
 PORT_TYPE_MPO = 2500
 PORT_TYPE_MPO = 2500
 PORT_TYPE_LSH = 2600
 PORT_TYPE_LSH = 2600
+PORT_TYPE_LSH_APC = 2610
 PORT_TYPE_CHOICES = [
 PORT_TYPE_CHOICES = [
     [
     [
         'Copper',
         'Copper',
@@ -288,10 +297,13 @@ PORT_TYPE_CHOICES = [
         [
         [
             [PORT_TYPE_FC, 'FC'],
             [PORT_TYPE_FC, 'FC'],
             [PORT_TYPE_LC, 'LC'],
             [PORT_TYPE_LC, 'LC'],
+            [PORT_TYPE_LC_APC, 'LC/APC'],
             [PORT_TYPE_LSH, 'LSH'],
             [PORT_TYPE_LSH, 'LSH'],
+            [PORT_TYPE_LSH_APC, 'LSH/APC'],
             [PORT_TYPE_MPO, 'MPO'],
             [PORT_TYPE_MPO, 'MPO'],
             [PORT_TYPE_MTRJ, 'MTRJ'],
             [PORT_TYPE_MTRJ, 'MTRJ'],
             [PORT_TYPE_SC, 'SC'],
             [PORT_TYPE_SC, 'SC'],
+            [PORT_TYPE_SC_APC, 'SC/APC'],
             [PORT_TYPE_ST, 'ST'],
             [PORT_TYPE_ST, 'ST'],
         ]
         ]
     ]
     ]
@@ -355,11 +367,14 @@ CABLE_TYPE_CAT6A = 1610
 CABLE_TYPE_CAT7 = 1700
 CABLE_TYPE_CAT7 = 1700
 CABLE_TYPE_DAC_ACTIVE = 1800
 CABLE_TYPE_DAC_ACTIVE = 1800
 CABLE_TYPE_DAC_PASSIVE = 1810
 CABLE_TYPE_DAC_PASSIVE = 1810
+CABLE_TYPE_MMF = 3000
 CABLE_TYPE_MMF_OM1 = 3010
 CABLE_TYPE_MMF_OM1 = 3010
 CABLE_TYPE_MMF_OM2 = 3020
 CABLE_TYPE_MMF_OM2 = 3020
 CABLE_TYPE_MMF_OM3 = 3030
 CABLE_TYPE_MMF_OM3 = 3030
 CABLE_TYPE_MMF_OM4 = 3040
 CABLE_TYPE_MMF_OM4 = 3040
 CABLE_TYPE_SMF = 3500
 CABLE_TYPE_SMF = 3500
+CABLE_TYPE_SMF_OS1 = 3510
+CABLE_TYPE_SMF_OS2 = 3520
 CABLE_TYPE_AOC = 3800
 CABLE_TYPE_AOC = 3800
 CABLE_TYPE_POWER = 5000
 CABLE_TYPE_POWER = 5000
 CABLE_TYPE_CHOICES = (
 CABLE_TYPE_CHOICES = (
@@ -377,11 +392,14 @@ CABLE_TYPE_CHOICES = (
     ),
     ),
     (
     (
         'Fiber', (
         'Fiber', (
+            (CABLE_TYPE_MMF, 'Multimode Fiber'),
             (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
             (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
             (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
             (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
             (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
             (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
             (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
             (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
             (CABLE_TYPE_SMF, 'Singlemode Fiber'),
             (CABLE_TYPE_SMF, 'Singlemode Fiber'),
+            (CABLE_TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
+            (CABLE_TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
             (CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
             (CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
         ),
         ),
     ),
     ),

+ 5 - 1
netbox/dcim/filters.py

@@ -543,6 +543,10 @@ class DeviceFilter(CustomFieldFilterSet):
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label='Rack (ID)',
         label='Rack (ID)',
     )
     )
+    position = django_filters.ChoiceFilter(
+        choices=DEVICE_POSITION_CHOICES,
+        null_label='Non-racked'
+    )
     cluster_id = django_filters.ModelMultipleChoiceFilter(
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         label='VM cluster (ID)',
         label='VM cluster (ID)',
@@ -602,7 +606,7 @@ class DeviceFilter(CustomFieldFilterSet):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['serial', 'position']
+        fields = ['serial', 'face']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 8 - 3
netbox/dcim/forms.py

@@ -2362,7 +2362,7 @@ class FrontPortCreateForm(ComponentForm):
 
 
 class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=FrontPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -2436,7 +2436,7 @@ class RearPortCreateForm(ComponentForm):
 
 
 class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=RearPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -2753,10 +2753,15 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         label='Search'
         label='Search'
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
-        choices=CABLE_TYPE_CHOICES,
+        choices=add_blank_choice(CABLE_TYPE_CHOICES),
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
+    status = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
+        widget=StaticSelect2()
+    )
     color = forms.CharField(
     color = forms.CharField(
         max_length=6,
         max_length=6,
         required=False,
         required=False,

+ 38 - 0
netbox/dcim/migrations/0069_deprecate_nullablecharfield.py

@@ -0,0 +1,38 @@
+# Generated by Django 2.1.5 on 2019-02-14 14:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0068_rack_new_fields'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='device',
+            name='asset_tag',
+            field=models.CharField(blank=True, max_length=50, null=True, unique=True),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='name',
+            field=models.CharField(blank=True, max_length=64, null=True, unique=True),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='asset_tag',
+            field=models.CharField(blank=True, max_length=50, null=True, unique=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='asset_tag',
+            field=models.CharField(blank=True, max_length=50, null=True, unique=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='facility_id',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+    ]

+ 10 - 8
netbox/dcim/models.py

@@ -16,7 +16,7 @@ from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
-from utilities.fields import ColorField, NullableCharField
+from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object, to_meters
 from utilities.utils import serialize_object, to_meters
@@ -217,8 +217,7 @@ class Region(MPTTModel, ChangeLoggedModel):
             self.parent.name if self.parent else None,
             self.parent.name if self.parent else None,
         )
         )
 
 
-    @property
-    def site_count(self):
+    def get_site_count(self):
         return Site.objects.filter(
         return Site.objects.filter(
             Q(region=self) |
             Q(region=self) |
             Q(region__in=self.get_descendants())
             Q(region__in=self.get_descendants())
@@ -470,7 +469,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
-    facility_id = NullableCharField(
+    facility_id = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -511,7 +510,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         blank=True,
         verbose_name='Serial number'
         verbose_name='Serial number'
     )
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -1354,7 +1353,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    name = NullableCharField(
+    name = models.CharField(
         max_length=64,
         max_length=64,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -1365,7 +1364,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         blank=True,
         verbose_name='Serial number'
         verbose_name='Serial number'
     )
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -2389,7 +2388,7 @@ class InventoryItem(ComponentModel):
         verbose_name='Serial number',
         verbose_name='Serial number',
         blank=True
         blank=True
     )
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         max_length=50,
         unique=True,
         unique=True,
         blank=True,
         blank=True,
@@ -2652,6 +2651,9 @@ class Cable(ChangeLoggedModel):
             self.length_unit,
             self.length_unit,
         )
         )
 
 
+    def get_status_class(self):
+        return 'success' if self.status else 'info'
+
     def get_path_endpoints(self):
     def get_path_endpoints(self):
         """
         """
         Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
         Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be

+ 3 - 0
netbox/dcim/tables.py

@@ -647,6 +647,9 @@ class CableTable(BaseTable):
         orderable=False,
         orderable=False,
         verbose_name=''
         verbose_name=''
     )
     )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
     length = tables.TemplateColumn(
     length = tables.TemplateColumn(
         template_code=CABLE_LENGTH,
         template_code=CABLE_LENGTH,
         order_by='_abs_length'
         order_by='_abs_length'

+ 458 - 0
netbox/dcim/tests/test_views.py

@@ -0,0 +1,458 @@
+import urllib.parse
+
+from django.contrib.auth import get_user_model
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from dcim.constants import CABLE_TYPE_CAT6, IFACE_FF_1GE_FIXED
+from dcim.models import (
+    Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
+    RackReservation, RackRole, Site, Region, VirtualChassis,
+)
+
+
+class RegionTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        # Create three Regions
+        for i in range(1, 4):
+            Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save()
+
+    def test_region_list(self):
+
+        url = reverse('dcim:region_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class SiteTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        region = Region(name='Region 1', slug='region-1')
+        region.save()
+
+        Site.objects.bulk_create([
+            Site(name='Site 1', slug='site-1', region=region),
+            Site(name='Site 2', slug='site-2', region=region),
+            Site(name='Site 3', slug='site-3', region=region),
+        ])
+
+    def test_site_list(self):
+
+        url = reverse('dcim:site_list')
+        params = {
+            "region": Region.objects.first().slug,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_site(self):
+
+        site = Site.objects.first()
+        response = self.client.get(site.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class RackGroupTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        RackGroup.objects.bulk_create([
+            RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
+            RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
+            RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
+        ])
+
+    def test_rackgroup_list(self):
+
+        url = reverse('dcim:rackgroup_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class RackTypeTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        RackRole.objects.bulk_create([
+            RackRole(name='Rack Role 1', slug='rack-role-1'),
+            RackRole(name='Rack Role 2', slug='rack-role-2'),
+            RackRole(name='Rack Role 3', slug='rack-role-3'),
+        ])
+
+    def test_rackrole_list(self):
+
+        url = reverse('dcim:rackrole_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class RackReservationTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        User = get_user_model()
+        user = User(username='testuser', email='testuser@example.com')
+        user.save()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        rack = Rack(name='Rack 1', site=site)
+        rack.save()
+
+        RackReservation.objects.bulk_create([
+            RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'),
+            RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'),
+            RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'),
+        ])
+
+    def test_rackreservation_list(self):
+
+        url = reverse('dcim:rackreservation_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class RackTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        Rack.objects.bulk_create([
+            Rack(name='Rack 1', site=site),
+            Rack(name='Rack 2', site=site),
+            Rack(name='Rack 3', site=site),
+        ])
+
+    def test_rack_list(self):
+
+        url = reverse('dcim:rack_list')
+        params = {
+            "site": Site.objects.first().slug,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_rack(self):
+
+        rack = Rack.objects.first()
+        response = self.client.get(rack.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class ManufacturerTypeTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        Manufacturer.objects.bulk_create([
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+            Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
+        ])
+
+    def test_manufacturer_list(self):
+
+        url = reverse('dcim:manufacturer_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class DeviceTypeTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
+        manufacturer.save()
+
+        DeviceType.objects.bulk_create([
+            DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer),
+            DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer),
+            DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer),
+        ])
+
+    def test_devicetype_list(self):
+
+        url = reverse('dcim:devicetype_list')
+        params = {
+            "manufacturer": Manufacturer.objects.first().slug,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_devicetype(self):
+
+        devicetype = DeviceType.objects.first()
+        response = self.client.get(devicetype.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class DeviceRoleTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        DeviceRole.objects.bulk_create([
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        ])
+
+    def test_devicerole_list(self):
+
+        url = reverse('dcim:devicerole_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class PlatformTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        Platform.objects.bulk_create([
+            Platform(name='Platform 1', slug='platform-1'),
+            Platform(name='Platform 2', slug='platform-2'),
+            Platform(name='Platform 3', slug='platform-3'),
+        ])
+
+    def test_platform_list(self):
+
+        url = reverse('dcim:platform_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
+class DeviceTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
+        manufacturer.save()
+
+        devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
+        devicetype.save()
+
+        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
+        devicerole.save()
+
+        Device.objects.bulk_create([
+            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+        ])
+
+    def test_device_list(self):
+
+        url = reverse('dcim:device_list')
+        params = {
+            "device_type_id": DeviceType.objects.first().pk,
+            "role": DeviceRole.objects.first().slug,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_device(self):
+
+        device = Device.objects.first()
+        response = self.client.get(device.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class InventoryItemTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
+        manufacturer.save()
+
+        devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
+        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()
+
+        InventoryItem.objects.bulk_create([
+            InventoryItem(device=device, name='Inventory Item 1'),
+            InventoryItem(device=device, name='Inventory Item 2'),
+            InventoryItem(device=device, name='Inventory Item 3'),
+        ])
+
+    def test_inventoryitem_list(self):
+
+        url = reverse('dcim:inventoryitem_list')
+        params = {
+            "device_id": Device.objects.first().pk,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_inventoryitem(self):
+
+        inventoryitem = InventoryItem.objects.first()
+        response = self.client.get(inventoryitem.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class CableTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
+        manufacturer.save()
+
+        devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
+        devicetype.save()
+
+        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
+        devicerole.save()
+
+        device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
+        device1.save()
+        device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
+        device2.save()
+
+        iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
+        iface1.save()
+        iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
+        iface2.save()
+        iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
+        iface3.save()
+        iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
+        iface4.save()
+        iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
+        iface5.save()
+        iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
+        iface6.save()
+
+        Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
+        Cable(termination_a=iface2, termination_b=iface5, type=CABLE_TYPE_CAT6).save()
+        Cable(termination_a=iface3, termination_b=iface6, type=CABLE_TYPE_CAT6).save()
+
+    def test_cable_list(self):
+
+        url = reverse('dcim:cable_list')
+        params = {
+            "type": CABLE_TYPE_CAT6,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_cable(self):
+
+        cable = Cable.objects.first()
+        response = self.client.get(cable.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class VirtualMachineTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        device_role = DeviceRole.objects.create(
+            name='Device Role', slug='device-role-1'
+        )
+
+        # Create 9 member Devices
+        device1 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 1', site=site
+        )
+        device2 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 2', site=site
+        )
+        device3 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 3', site=site
+        )
+        device4 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 4', site=site
+        )
+        device5 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 5', site=site
+        )
+        device6 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='Device 6', site=site
+        )
+
+        # Create three VirtualChassis with two members each
+        vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1')
+        Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2)
+        vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2')
+        Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
+        vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
+        Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
+
+    def test_virtualchassis_list(self):
+
+        url = reverse('dcim:virtualchassis_list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+    def test_virtualchassis(self):
+
+        virtualchassis = VirtualChassis.objects.first()
+        response = self.client.get(virtualchassis.get_absolute_url())
+        self.assertEqual(response.status_code, 200)

+ 7 - 1
netbox/dcim/views.py

@@ -135,7 +135,13 @@ class BulkDisconnectView(GetReturnURLMixin, View):
 #
 #
 
 
 class RegionListView(ObjectListView):
 class RegionListView(ObjectListView):
-    queryset = Region.objects.all()
+    queryset = Region.objects.add_related_count(
+        Region.objects.all(),
+        Site,
+        'region',
+        'site_count',
+        cumulative=True
+    )
     filter = filters.RegionFilter
     filter = filters.RegionFilter
     filter_form = forms.RegionFilterForm
     filter_form = forms.RegionFilterForm
     table = tables.RegionTable
     table = tables.RegionTable

+ 53 - 13
netbox/extras/forms.py

@@ -11,8 +11,8 @@ from taggit.models import Tag
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
-    FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
+    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
+    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
 )
 )
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -221,10 +221,6 @@ class TagFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    regions = TreeNodeMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
     data = JSONField()
     data = JSONField()
 
 
     class Meta:
     class Meta:
@@ -233,6 +229,26 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
             'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
             'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
             'tenants', 'data',
             'tenants', 'data',
         ]
         ]
+        widgets = {
+            'regions': APISelectMultiple(
+                api_url="/api/dcim/regions/"
+            ),
+            'sites': APISelectMultiple(
+                api_url="/api/dcim/sites/"
+            ),
+            'roles': APISelectMultiple(
+                api_url="/api/dcim/device-roles/"
+            ),
+            'platforms': APISelectMultiple(
+                api_url="/api/dcim/platforms/"
+            ),
+            'tenant_groups': APISelectMultiple(
+                api_url="/api/tenancy/tenant-groups/"
+            ),
+            'tenants': APISelectMultiple(
+                api_url="/api/tenancy/tenants/"
+            )
+        }
 
 
 
 
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -264,29 +280,53 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterTreeNodeMultipleChoiceField(
+    region = FilterChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+        )
     )
     )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
     )
     )
     role = FilterChoiceField(
     role = FilterChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/device-roles/",
+            value_field="slug",
+        )
     )
     )
     platform = FilterChoiceField(
     platform = FilterChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/platforms/",
+            value_field="slug",
+        )
     )
     )
     tenant_group = FilterChoiceField(
     tenant_group = FilterChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenant-groups/",
+            value_field="slug",
+        )
     )
     )
     tenant = FilterChoiceField(
     tenant = FilterChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+        )
     )
     )
 
 
 
 

+ 105 - 0
netbox/extras/tests/test_views.py

@@ -0,0 +1,105 @@
+import urllib.parse
+import uuid
+
+from django.contrib.auth.models import User
+from django.test import Client, TestCase
+from django.urls import reverse
+from taggit.models import Tag
+
+from dcim.models import Site
+from extras.models import ConfigContext, ObjectChange
+
+
+class TagTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        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",
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+
+class ConfigContextTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        # Create three ConfigContexts
+        for i in range(1, 4):
+            configcontext = ConfigContext(
+                name='Config Context {}'.format(i),
+                data='{{"foo": {}}}'.format(i)
+            )
+            configcontext.save()
+            configcontext.sites.add(site)
+
+    def test_configcontext_list(self):
+
+        url = reverse('extras:configcontext_list')
+        params = {
+            "q": "foo",
+        }
+
+        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):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        user = User(username='testuser', email='testuser@example.com')
+        user.save()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        # Create three ObjectChanges
+        for i in range(1, 4):
+            site.log_change(
+                user=user,
+                request_id=uuid.uuid4(),
+                action=2
+            )
+
+    def test_objectchange_list(self):
+
+        url = reverse('extras:objectchange_list')
+        params = {
+            "user": User.objects.first(),
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_objectchange(self):
+
+        objectchange = ObjectChange.objects.first()
+        response = self.client.get(objectchange.get_absolute_url())
+        self.assertEqual(response.status_code, 200)

+ 282 - 0
netbox/ipam/tests/test_views.py

@@ -0,0 +1,282 @@
+from netaddr import IPNetwork
+import urllib.parse
+
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
+from ipam.constants import IP_PROTOCOL_TCP
+from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
+
+
+class VRFTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        VRF.objects.bulk_create([
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
+            VRF(name='VRF 3', rd='65000:3'),
+        ])
+
+    def test_vrf_list(self):
+
+        url = reverse('ipam:vrf_list')
+        params = {
+            "q": "65000",
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_configcontext(self):
+
+        vrf = VRF.objects.first()
+        response = self.client.get(vrf.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class RIRTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        RIR.objects.bulk_create([
+            RIR(name='RIR 1', slug='rir-1'),
+            RIR(name='RIR 2', slug='rir-2'),
+            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(self):
+
+        rir = RIR.objects.first()
+        response = self.client.get(rir.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+
+
+class AggregateTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        rir = RIR(name='RIR 1', slug='rir-1')
+        rir.save()
+
+        Aggregate.objects.bulk_create([
+            Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
+            Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
+            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,
+        }
+
+        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)
+
+
+class RoleTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        Role.objects.bulk_create([
+            Role(name='Role 1', slug='role-1'),
+            Role(name='Role 2', slug='role-2'),
+            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)
+
+
+class PrefixTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        Prefix.objects.bulk_create([
+            Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
+            Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
+            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,
+        }
+
+        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)
+
+
+class IPAddressTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        vrf = VRF(name='VRF 1', rd='65000:1')
+        vrf.save()
+
+        IPAddress.objects.bulk_create([
+            IPAddress(family=4, address=IPNetwork('10.1.0.0/16'), vrf=vrf),
+            IPAddress(family=4, address=IPNetwork('10.2.0.0/16'), vrf=vrf),
+            IPAddress(family=4, address=IPNetwork('10.3.0.0/16'), vrf=vrf),
+        ])
+
+    def test_ipaddress_list(self):
+
+        url = reverse('ipam:ipaddress_list')
+        params = {
+            "vrf": VRF.objects.first().rd,
+        }
+
+        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)
+
+
+class VLANGroupTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        site = Site(name='Site 1', slug='site-1')
+        site.save()
+
+        VLANGroup.objects.bulk_create([
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site),
+            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,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)
+
+
+class VLANTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
+        vlangroup.save()
+
+        VLAN.objects.bulk_create([
+            VLAN(group=vlangroup, vid=101, name='VLAN101'),
+            VLAN(group=vlangroup, vid=102, name='VLAN102'),
+            VLAN(group=vlangroup, vid=103, name='VLAN103'),
+        ])
+
+    def test_vlan_list(self):
+
+        url = reverse('ipam:vlan_list')
+        params = {
+            "group": VLANGroup.objects.first().slug,
+        }
+
+        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)
+
+
+class ServiceTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        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()
+
+        Service.objects.bulk_create([
+            Service(device=device, name='Service 1', protocol=IP_PROTOCOL_TCP, port=101),
+            Service(device=device, name='Service 2', protocol=IP_PROTOCOL_TCP, port=102),
+            Service(device=device, name='Service 3', protocol=IP_PROTOCOL_TCP, port=103),
+        ])
+
+    def test_service_list(self):
+
+        url = reverse('ipam:service_list')
+        params = {
+            "device_id": Device.objects.first(),
+        }
+
+        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)

+ 1 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.5.6'
+VERSION = '2.5.7'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

Fișier diff suprimat deoarece este prea mare
+ 6 - 0
netbox/project-static/clipboard-2.0.4.min.js


+ 4 - 0
netbox/project-static/js/secrets.js

@@ -1,4 +1,6 @@
 $(document).ready(function() {
 $(document).ready(function() {
+    // Instantiate ClipboardJS on all copy buttons
+    new ClipboardJS('button.copy-secret');
 
 
     // Unlocking a secret
     // Unlocking a secret
     $('button.unlock-secret').click(function(event) {
     $('button.unlock-secret').click(function(event) {
@@ -45,6 +47,7 @@ $(document).ready(function() {
                     console.log("Secret retrieved successfully");
                     console.log("Secret retrieved successfully");
                     $('#secret_' + secret_id).text(response.plaintext);
                     $('#secret_' + secret_id).text(response.plaintext);
                     $('button.unlock-secret[secret-id=' + secret_id + ']').hide();
                     $('button.unlock-secret[secret-id=' + secret_id + ']').hide();
+                    $('button.copy-secret[secret-id=' + secret_id + ']').show();
                     $('button.lock-secret[secret-id=' + secret_id + ']').show();
                     $('button.lock-secret[secret-id=' + secret_id + ']').show();
                 } else {
                 } else {
                     console.log("Secret was not decrypted. Prompt user for private key.");
                     console.log("Secret was not decrypted. Prompt user for private key.");
@@ -67,6 +70,7 @@ $(document).ready(function() {
         var secret_div = $('#secret_' + secret_id);
         var secret_div = $('#secret_' + secret_id);
         secret_div.html('********');
         secret_div.html('********');
         $('button.lock-secret[secret-id=' + secret_id + ']').hide();
         $('button.lock-secret[secret-id=' + secret_id + ']').hide();
+        $('button.copy-secret[secret-id=' + secret_id + ']').hide();
         $('button.unlock-secret[secret-id=' + secret_id + ']').show();
         $('button.unlock-secret[secret-id=' + secret_id + ']').show();
     }
     }
 
 

+ 82 - 0
netbox/secrets/tests/test_views.py

@@ -0,0 +1,82 @@
+import urllib.parse
+
+from django.contrib.auth import get_user_model
+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
+
+
+class SecretRoleTestCase(TestCase):
+
+    def setUp(self):
+
+        TEST_USERNAME = 'testuser'
+        TEST_PASSWORD = 'testpassword'
+
+        User = get_user_model()
+        User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD)
+
+        self.client = Client()
+        self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD)
+
+        SecretRole.objects.bulk_create([
+            SecretRole(name='Secret Role 1', slug='secret-role-1'),
+            SecretRole(name='Secret Role 2', slug='secret-role-2'),
+            SecretRole(name='Secret Role 3', slug='secret-role-3'),
+        ])
+
+    def test_secretrole_list(self):
+
+        url = reverse('secrets:secret_list')
+
+        response = self.client.get(url, follow=True)
+        self.assertEqual(response.status_code, 200)
+
+
+class SecretTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        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()
+
+        Secret.objects.bulk_create([
+            Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
+            Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'),
+            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,
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
+        self.assertEqual(response.status_code, 200)
+
+    def test_configcontext(self):
+
+        secret = Secret.objects.first()
+        response = self.client.get(secret.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)

+ 1 - 0
netbox/templates/_base.html

@@ -69,6 +69,7 @@
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
 <script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
+<script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script type="text/javascript">
 <script type="text/javascript">
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";

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

@@ -85,11 +85,11 @@
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>NOC Contact</td>
                     <td>NOC Contact</td>
-                    <td>{{ provider.noc_contact|linebreaksbr|placeholder }}</td>
+                    <td class="rendered-markdown">{{ provider.noc_contact|gfm|placeholder }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Admin Contact</td>
                     <td>Admin Contact</td>
-                    <td>{{ provider.admin_contact|linebreaksbr|placeholder }}</td>
+                    <td class="rendered-markdown">{{ provider.admin_contact|gfm|placeholder }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Circuits</td>
                     <td>Circuits</td>

+ 2 - 2
netbox/templates/dcim/inc/interface.html

@@ -96,7 +96,7 @@
                             {{ peer_termination.connected_endpoint.device }}
                             {{ peer_termination.connected_endpoint.device }}
                         </a><br/>
                         </a><br/>
                         <small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
                         <small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
-                            <a href="{{ iface.connected_endpoint.circuit.get_absolure_url }}">
+                            <a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
                                 {{ iface.connected_endpoint.circuit.provider }}
                                 {{ iface.connected_endpoint.circuit.provider }}
                                 {{ iface.connected_endpoint.circuit }}
                                 {{ iface.connected_endpoint.circuit }}
                             </a>
                             </a>
@@ -150,7 +150,7 @@
         {% if perms.dcim.change_interface %}
         {% if perms.dcim.change_interface %}
             {% if iface.cable %}
             {% if iface.cable %}
                 {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
                 {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
-            {% elif not iface.is_virtual and perms.dcim.add_cable %}
+            {% elif iface.is_connectable and perms.dcim.add_cable %}
                 <a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
                 <a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>
                 </a>

+ 1 - 1
netbox/templates/dcim/inc/rack_elevation.html

@@ -25,7 +25,7 @@
             {% if u.device %}
             {% if u.device %}
                 <li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
                 <li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
                     {% ifequal u.device.face face_id %}
                     {% ifequal u.device.face face_id %}
-                        <a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
+                        <a href="{% url 'dcim:device' pk=u.device.pk %}" style="color: {{ u.device.device_role.color|fgcolor }}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
                            data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.display_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
                            data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.display_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
                             {{ u.device }}
                             {{ u.device }}
                             {% if u.device.devicebay_count %}
                             {% if u.device.devicebay_count %}

+ 14 - 5
netbox/templates/dcim/interface_edit.html

@@ -14,20 +14,29 @@
             {% render_field form.mgmt_only %}
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
             {% render_field form.description %}
             {% render_field form.mode %}
             {% render_field form.mode %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
             {% render_field form.tags %}
             {% render_field form.tags %}
         </div>
         </div>
     </div>
     </div>
-    {% if obj.mode %}
-        <div class="panel panel-default" id="vlans_panel">
-            <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
+    <div class="panel panel-default" id="vlans_panel">
+        <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
+        {% if obj.mode %}
             {% include 'dcim/inc/interface_vlans_table.html' %}
             {% include 'dcim/inc/interface_vlans_table.html' %}
             <div class="panel-footer text-right">
             <div class="panel-footer text-right">
                 <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
                 <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
                     <i class="glyphicon glyphicon-plus"></i> Add VLANs
                     <i class="glyphicon glyphicon-plus"></i> Add VLANs
                 </a>
                 </a>
             </div>
             </div>
-        </div>
-    {% endif %}
+        {% else %}
+            <div class="panel-body text-center text-muted">
+                <p>802.1Q mode not set</p>
+            </div>
+        {% endif %}
+    </div>
 {% endblock %}
 {% endblock %}
 
 
 {% block buttons %}
 {% block buttons %}

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -67,7 +67,7 @@
                     <td>VRF</td>
                     <td>VRF</td>
                     <td>
                     <td>
                         {% if ipaddress.vrf %}
                         {% if ipaddress.vrf %}
-                            <a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a> ({{ ipaddress.vrf.rd }})
+                            <a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a>
                         {% else %}
                         {% else %}
                             <span>Global</span>
                             <span>Global</span>
                         {% endif %}
                         {% endif %}

+ 1 - 1
netbox/templates/ipam/vrf.html

@@ -87,7 +87,7 @@
                 <tr>
                 <tr>
                     <td>Prefixes</td>
                     <td>Prefixes</td>
                     <td>
                     <td>
-                        <a href="{% url 'ipam:prefix_list' %}?vrf={{ vrf.rd }}">{{ prefix_count }}</a>
+                        <a href="{% url 'ipam:prefix_list' %}?vrf_id={{ vrf.pk }}">{{ prefix_count }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>

+ 3 - 0
netbox/templates/secrets/inc/secret_tr.html

@@ -8,6 +8,9 @@
             <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
             <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
                 <i class="fa fa-lock"></i> Unlock
                 <i class="fa fa-lock"></i> Unlock
             </button>
             </button>
+            <button class="btn btn-xs btn-default copy-secret collapse" secret-id="{{ secret.pk }}" data-clipboard-target="#secret_{{ secret.pk }}">
+                <i class="fa fa-copy"></i> Copy
+            </button>
             <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
             <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
                 <i class="fa fa-unlock-alt"></i> Lock
                 <i class="fa fa-unlock-alt"></i> Lock
             </button>
             </button>

+ 5 - 2
netbox/templates/secrets/secret.html

@@ -77,11 +77,14 @@
                     </form>
                     </form>
                     <div class="row">
                     <div class="row">
                         <div class="col-md-2">Secret</div>
                         <div class="col-md-2">Secret</div>
-                        <div class="col-md-8" id="secret_{{ secret.pk }}">********</div>
-                        <div class="col-md-2 text-right">
+                        <div class="col-md-6" id="secret_{{ secret.pk }}">********</div>
+                        <div class="col-md-4 text-right">
                             <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
                             <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
                                 <i class="fa fa-lock"></i> Unlock
                                 <i class="fa fa-lock"></i> Unlock
                             </button>
                             </button>
+                            <button class="btn btn-xs btn-default copy-secret collapse" secret-id="{{ secret.pk }}" data-clipboard-target="#secret_{{ secret.pk }}">
+                                <i class="fa fa-copy"></i> Copy
+                            </button>
                             <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
                             <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
                                 <i class="fa fa-unlock-alt"></i> Lock
                                 <i class="fa fa-unlock-alt"></i> Lock
                             </button>
                             </button>

+ 8 - 0
netbox/templates/utilities/obj_bulk_edit.html

@@ -4,6 +4,14 @@
 
 
 {% block content %}
 {% block content %}
 <h1>{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
 <h1>{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
+{% if form.errors %}
+    <div class="panel panel-danger">
+        <div class="panel-heading"><strong>Errors</strong></div>
+        <div class="panel-body">
+            {{ form.errors }}
+        </div>
+    </div>
+{% endif %}
 <form action="" method="post" class="form form-horizontal">
 <form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     {% if request.POST.return_url %}
     {% if request.POST.return_url %}

+ 58 - 0
netbox/tenancy/tests/test_views.py

@@ -0,0 +1,58 @@
+import urllib.parse
+
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from tenancy.models import Tenant, TenantGroup
+
+
+class TenantGroupTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        TenantGroup.objects.bulk_create([
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
+            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)
+
+
+class TenantTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1')
+        tenantgroup.save()
+
+        Tenant.objects.bulk_create([
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroup),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroup),
+            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,
+        }
+
+        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)

+ 2 - 0
netbox/utilities/fields.py

@@ -10,6 +10,8 @@ ColorValidator = RegexValidator(
 )
 )
 
 
 
 
+# Deprecated: Retained only to ensure successful migration from early releases
+# Use models.CharField(null=True) instead
 class NullableCharField(models.CharField):
 class NullableCharField(models.CharField):
     description = "Stores empty values as NULL rather than ''"
     description = "Stores empty values as NULL rather than ''"
 
 

+ 3 - 3
netbox/utilities/filters.py

@@ -1,4 +1,5 @@
 import django_filters
 import django_filters
+from django.conf import settings
 from django.db.models import Q
 from django.db.models import Q
 from taggit.models import Tag
 from taggit.models import Tag
 
 
@@ -14,12 +15,11 @@ class NullableCharFieldFilter(django_filters.CharFilter):
     """
     """
     Allow matching on null field values by passing a special string used to signify NULL.
     Allow matching on null field values by passing a special string used to signify NULL.
     """
     """
-    null_value = 'NULL'
 
 
     def filter(self, qs, value):
     def filter(self, qs, value):
-        if value != self.null_value:
+        if value != settings.FILTERS_NULL_CHOICE_VALUE:
             return super().filter(qs, value)
             return super().filter(qs, value)
-        qs = self.get_method(qs)(**{'{}__isnull'.format(self.name): True})
+        qs = self.get_method(qs)(**{'{}__isnull'.format(self.field_name): True})
         return qs.distinct() if self.distinct else qs
         return qs.distinct() if self.distinct else qs
 
 
 
 

+ 117 - 0
netbox/virtualization/tests/test_views.py

@@ -0,0 +1,117 @@
+import urllib.parse
+
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+class ClusterGroupTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        ClusterGroup.objects.bulk_create([
+            ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
+            ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
+            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)
+
+
+class ClusterTypeTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        ClusterType.objects.bulk_create([
+            ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
+            ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
+            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)
+
+
+class ClusterTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
+        clustergroup.save()
+
+        clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
+        clustertype.save()
+
+        Cluster.objects.bulk_create([
+            Cluster(name='Cluster 1', group=clustergroup, type=clustertype),
+            Cluster(name='Cluster 2', group=clustergroup, type=clustertype),
+            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,
+        }
+
+        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)
+
+
+class VirtualMachineTestCase(TestCase):
+
+    def setUp(self):
+
+        self.client = Client()
+
+        clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
+        clustertype.save()
+
+        cluster = Cluster(name='Cluster 1', type=clustertype)
+        cluster.save()
+
+        VirtualMachine.objects.bulk_create([
+            VirtualMachine(name='Virtual Machine 1', cluster=cluster),
+            VirtualMachine(name='Virtual Machine 2', cluster=cluster),
+            VirtualMachine(name='Virtual Machine 3', cluster=cluster),
+        ])
+
+    def test_virtualmachine_list(self):
+
+        url = reverse('virtualization:virtualmachine_list')
+        params = {
+            "cluster_id": Cluster.objects.first().pk,
+        }
+
+        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)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff