فهرست منبع

Merge pull request #2922 from digitalocean/develop

Release v2.5.7
Jeremy Stretch 7 سال پیش
والد
کامیت
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)
 
 ## 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>
 ```
 
-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'],
 ]
 
+# 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
 SUBDEVICE_ROLE_PARENT = True
 SUBDEVICE_ROLE_CHILD = False
@@ -270,11 +276,14 @@ PORT_TYPE_8P8C = 1000
 PORT_TYPE_110_PUNCH = 1100
 PORT_TYPE_ST = 2000
 PORT_TYPE_SC = 2100
+PORT_TYPE_SC_APC = 2110
 PORT_TYPE_FC = 2200
 PORT_TYPE_LC = 2300
+PORT_TYPE_LC_APC = 2310
 PORT_TYPE_MTRJ = 2400
 PORT_TYPE_MPO = 2500
 PORT_TYPE_LSH = 2600
+PORT_TYPE_LSH_APC = 2610
 PORT_TYPE_CHOICES = [
     [
         'Copper',
@@ -288,10 +297,13 @@ PORT_TYPE_CHOICES = [
         [
             [PORT_TYPE_FC, 'FC'],
             [PORT_TYPE_LC, 'LC'],
+            [PORT_TYPE_LC_APC, 'LC/APC'],
             [PORT_TYPE_LSH, 'LSH'],
+            [PORT_TYPE_LSH_APC, 'LSH/APC'],
             [PORT_TYPE_MPO, 'MPO'],
             [PORT_TYPE_MTRJ, 'MTRJ'],
             [PORT_TYPE_SC, 'SC'],
+            [PORT_TYPE_SC_APC, 'SC/APC'],
             [PORT_TYPE_ST, 'ST'],
         ]
     ]
@@ -355,11 +367,14 @@ CABLE_TYPE_CAT6A = 1610
 CABLE_TYPE_CAT7 = 1700
 CABLE_TYPE_DAC_ACTIVE = 1800
 CABLE_TYPE_DAC_PASSIVE = 1810
+CABLE_TYPE_MMF = 3000
 CABLE_TYPE_MMF_OM1 = 3010
 CABLE_TYPE_MMF_OM2 = 3020
 CABLE_TYPE_MMF_OM3 = 3030
 CABLE_TYPE_MMF_OM4 = 3040
 CABLE_TYPE_SMF = 3500
+CABLE_TYPE_SMF_OS1 = 3510
+CABLE_TYPE_SMF_OS2 = 3520
 CABLE_TYPE_AOC = 3800
 CABLE_TYPE_POWER = 5000
 CABLE_TYPE_CHOICES = (
@@ -377,11 +392,14 @@ CABLE_TYPE_CHOICES = (
     ),
     (
         'Fiber', (
+            (CABLE_TYPE_MMF, 'Multimode Fiber'),
             (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
             (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
             (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
             (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
             (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)'),
         ),
     ),

+ 5 - 1
netbox/dcim/filters.py

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

+ 8 - 3
netbox/dcim/forms.py

@@ -2362,7 +2362,7 @@ class FrontPortCreateForm(ComponentForm):
 
 class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=FrontPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
@@ -2436,7 +2436,7 @@ class RearPortCreateForm(ComponentForm):
 
 class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=RearPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
     type = forms.ChoiceField(
@@ -2753,10 +2753,15 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         label='Search'
     )
     type = forms.MultipleChoiceField(
-        choices=CABLE_TYPE_CHOICES,
+        choices=add_blank_choice(CABLE_TYPE_CHOICES),
         required=False,
         widget=StaticSelect2()
     )
+    status = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
+        widget=StaticSelect2()
+    )
     color = forms.CharField(
         max_length=6,
         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 extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
-from utilities.fields import ColorField, NullableCharField
+from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object, to_meters
@@ -217,8 +217,7 @@ class Region(MPTTModel, ChangeLoggedModel):
             self.parent.name if self.parent else None,
         )
 
-    @property
-    def site_count(self):
+    def get_site_count(self):
         return Site.objects.filter(
             Q(region=self) |
             Q(region__in=self.get_descendants())
@@ -470,7 +469,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     name = models.CharField(
         max_length=50
     )
-    facility_id = NullableCharField(
+    facility_id = models.CharField(
         max_length=50,
         blank=True,
         null=True,
@@ -511,7 +510,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         verbose_name='Serial number'
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         blank=True,
         null=True,
@@ -1354,7 +1353,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         null=True
     )
-    name = NullableCharField(
+    name = models.CharField(
         max_length=64,
         blank=True,
         null=True,
@@ -1365,7 +1364,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         verbose_name='Serial number'
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         blank=True,
         null=True,
@@ -2389,7 +2388,7 @@ class InventoryItem(ComponentModel):
         verbose_name='Serial number',
         blank=True
     )
-    asset_tag = NullableCharField(
+    asset_tag = models.CharField(
         max_length=50,
         unique=True,
         blank=True,
@@ -2652,6 +2651,9 @@ class Cable(ChangeLoggedModel):
             self.length_unit,
         )
 
+    def get_status_class(self):
+        return 'success' if self.status else 'info'
+
     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

+ 3 - 0
netbox/dcim/tables.py

@@ -647,6 +647,9 @@ class CableTable(BaseTable):
         orderable=False,
         verbose_name=''
     )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
     length = tables.TemplateColumn(
         template_code=CABLE_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):
-    queryset = Region.objects.all()
+    queryset = Region.objects.add_related_count(
+        Region.objects.all(),
+        Site,
+        'region',
+        'site_count',
+        cumulative=True
+    )
     filter = filters.RegionFilter
     filter_form = forms.RegionFilterForm
     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 tenancy.models import Tenant, TenantGroup
 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 (
     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):
-    regions = TreeNodeMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False
-    )
     data = JSONField()
 
     class Meta:
@@ -233,6 +229,26 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
             'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
             '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):
@@ -264,29 +280,53 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
         required=False,
         label='Search'
     )
-    region = FilterTreeNodeMultipleChoiceField(
+    region = FilterChoiceField(
         queryset=Region.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+        )
     )
     site = FilterChoiceField(
         queryset=Site.objects.all(),
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
     )
     role = FilterChoiceField(
         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(
         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(
         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(
         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__)))
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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() {
+    // Instantiate ClipboardJS on all copy buttons
+    new ClipboardJS('button.copy-secret');
 
     // Unlocking a secret
     $('button.unlock-secret').click(function(event) {
@@ -45,6 +47,7 @@ $(document).ready(function() {
                     console.log("Secret retrieved successfully");
                     $('#secret_' + secret_id).text(response.plaintext);
                     $('button.unlock-secret[secret-id=' + secret_id + ']').hide();
+                    $('button.copy-secret[secret-id=' + secret_id + ']').show();
                     $('button.lock-secret[secret-id=' + secret_id + ']').show();
                 } else {
                     console.log("Secret was not decrypted. Prompt user for private key.");
@@ -67,6 +70,7 @@ $(document).ready(function() {
         var secret_div = $('#secret_' + secret_id);
         secret_div.html('********');
         $('button.lock-secret[secret-id=' + secret_id + ']').hide();
+        $('button.copy-secret[secret-id=' + secret_id + ']').hide();
         $('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 '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 'clipboard-2.0.4.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script type="text/javascript">
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";

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

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

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

@@ -96,7 +96,7 @@
                             {{ peer_termination.connected_endpoint.device }}
                         </a><br/>
                         <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 }}
                             </a>
@@ -150,7 +150,7 @@
         {% if perms.dcim.change_interface %}
             {% if 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">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>

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

@@ -25,7 +25,7 @@
             {% 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 %}>
                     {% 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 %}">
                             {{ u.device }}
                             {% 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.description %}
             {% 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 %}
         </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' %}
             <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 %}">
                     <i class="glyphicon glyphicon-plus"></i> Add VLANs
                 </a>
             </div>
-        </div>
-    {% endif %}
+        {% else %}
+            <div class="panel-body text-center text-muted">
+                <p>802.1Q mode not set</p>
+            </div>
+        {% endif %}
+    </div>
 {% endblock %}
 
 {% block buttons %}

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

@@ -67,7 +67,7 @@
                     <td>VRF</td>
                     <td>
                         {% 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 %}
                             <span>Global</span>
                         {% endif %}

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

@@ -87,7 +87,7 @@
                 <tr>
                     <td>Prefixes</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>
                 </tr>
 		    </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 }}">
                 <i class="fa fa-lock"></i> Unlock
             </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 }}">
                 <i class="fa fa-unlock-alt"></i> Lock
             </button>

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

@@ -77,11 +77,14 @@
                     </form>
                     <div class="row">
                         <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 }}">
                                 <i class="fa fa-lock"></i> Unlock
                             </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 }}">
                                 <i class="fa fa-unlock-alt"></i> Lock
                             </button>

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

@@ -4,6 +4,14 @@
 
 {% block content %}
 <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">
     {% csrf_token %}
     {% 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):
     description = "Stores empty values as NULL rather than ''"
 

+ 3 - 3
netbox/utilities/filters.py

@@ -1,4 +1,5 @@
 import django_filters
+from django.conf import settings
 from django.db.models import Q
 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.
     """
-    null_value = 'NULL'
 
     def filter(self, qs, value):
-        if value != self.null_value:
+        if value != settings.FILTERS_NULL_CHOICE_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
 
 

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است