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

Merge pull request #8814 from netbox-community/develop

Release v3.1.9
Jeremy Stretch пре 3 година
родитељ
комит
8053ea0a22
46 измењених фајлова са 493 додато и 198 уклоњено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 2 0
      README.md
  4. 2 0
      docs/index.md
  5. 27 0
      docs/release-notes/version-3.1.md
  6. 6 3
      mkdocs.yml
  7. 4 4
      netbox/circuits/filtersets.py
  8. 24 8
      netbox/circuits/tests/test_filtersets.py
  9. 12 0
      netbox/dcim/choices.py
  10. 4 4
      netbox/dcim/filtersets.py
  11. 13 0
      netbox/dcim/forms/bulk_import.py
  12. 5 4
      netbox/dcim/models/devices.py
  13. 6 2
      netbox/dcim/tables/sites.py
  14. 24 8
      netbox/dcim/tests/test_filtersets.py
  15. 5 4
      netbox/extras/filtersets.py
  16. 12 4
      netbox/extras/tests/test_filtersets.py
  17. 1 1
      netbox/ipam/api/serializers.py
  18. 2 0
      netbox/ipam/choices.py
  19. 9 9
      netbox/ipam/filtersets.py
  20. 4 4
      netbox/ipam/tables/fhrp.py
  21. 5 1
      netbox/ipam/tables/ip.py
  22. 54 18
      netbox/ipam/tests/test_filtersets.py
  23. 0 1
      netbox/ipam/views.py
  24. 1 1
      netbox/netbox/settings.py
  25. 0 0
      netbox/project-static/dist/netbox-dark.css
  26. 0 0
      netbox/project-static/dist/netbox.js
  27. 0 0
      netbox/project-static/dist/netbox.js.map
  28. 27 6
      netbox/project-static/src/forms/scopeSelector.ts
  29. 2 3
      netbox/project-static/styles/theme-dark.scss
  30. 6 2
      netbox/templates/base/base.html
  31. 48 46
      netbox/templates/base/layout.html
  32. 91 35
      netbox/templates/dcim/site.html
  33. 2 2
      netbox/templates/ipam/aggregate/prefixes.html
  34. 14 2
      netbox/templates/search.html
  35. 2 2
      netbox/tenancy/filtersets.py
  36. 12 4
      netbox/tenancy/tests/test_filtersets.py
  37. 2 2
      netbox/users/filtersets.py
  38. 12 4
      netbox/users/tests/test_filtersets.py
  39. 3 1
      netbox/utilities/forms/forms.py
  40. 6 0
      netbox/utilities/querysets.py
  41. 1 1
      netbox/virtualization/filtersets.py
  42. 6 2
      netbox/virtualization/tests/test_filtersets.py
  43. 2 2
      netbox/wireless/filtersets.py
  44. 14 4
      netbox/wireless/tests/test_filtersets.py
  45. 2 2
      requirements.txt
  46. 17 0
      upgrade.sh

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.8
+      placeholder: v3.1.9
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.8
+      placeholder: v3.1.9
     validations:
       required: true
   - type: dropdown

+ 2 - 0
README.md

@@ -2,6 +2,8 @@
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
 </div>
 
+:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
+
 ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
 
 NetBox is an infrastructure resource modeling (IRM) tool designed to empower

+ 2 - 0
docs/index.md

@@ -1,5 +1,7 @@
 ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
 
+:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
+
 # What is NetBox?
 
 NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management:

+ 27 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,32 @@
 # NetBox v3.1
 
+## v3.1.9 (2022-03-07)
+
+### Enhancements
+
+* [#8594](https://github.com/netbox-community/netbox/issues/8594) - Enable filtering by exact description match for all applicable models
+* [#8629](https://github.com/netbox-community/netbox/issues/8629) - Add description to tag table search function
+* [#8664](https://github.com/netbox-community/netbox/issues/8664) - Show assigned ASNs/sites under list views
+* [#8736](https://github.com/netbox-community/netbox/issues/8736) - Add PC and UPC fiber end faces for LC/SC/LSH port types
+* [#8758](https://github.com/netbox-community/netbox/issues/8758) - Allow empty string substitution when renaming objects in bulk
+* [#8762](https://github.com/netbox-community/netbox/issues/8762) - Link to rack elevations list from site view
+* [#8766](https://github.com/netbox-community/netbox/issues/8766) - Add SCTP to service protocols list
+
+### Bug Fixes
+
+* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces
+* [#8633](https://github.com/netbox-community/netbox/issues/8633) - Prevent navigation sidebar pin from disappearing at certain breakpoints
+* [#8674](https://github.com/netbox-community/netbox/issues/8674) - Fix rendering of tabbed content in documentation
+* [#8710](https://github.com/netbox-community/netbox/issues/8710) - Fix dynamic scope selection form fields when creating a VLAN group
+* [#8713](https://github.com/netbox-community/netbox/issues/8713) - Restore missing "add" button on services list view
+* [#8715](https://github.com/netbox-community/netbox/issues/8715) - Avoid returning multiple objects when restricting querysets using multiple tags in permissions
+* [#8717](https://github.com/netbox-community/netbox/issues/8717) - Fix redirection after bulk edit/delete of prefixes from aggregate view
+* [#8724](https://github.com/netbox-community/netbox/issues/8724) - Fix exception during device import with invalid device type
+* [#8807](https://github.com/netbox-community/netbox/issues/8807) - Correct REST API URL for FHRP group assignments
+* [#8808](https://github.com/netbox-community/netbox/issues/8808) - Fix members count under FHRP group list
+
+---
+
 ## v3.1.8 (2022-02-15)
 
 ### Enhancements

+ 6 - 3
mkdocs.yml

@@ -8,11 +8,13 @@ theme:
   icon:
     repo: fontawesome/brands/github
   palette:
-    - scheme: default
+    - media: "(prefers-color-scheme: light)"
+      scheme: default
       toggle:
         icon: material/lightbulb-outline
         name: Switch to Dark Mode
-    - scheme: slate
+    - media: "(prefers-color-scheme: dark)"
+      scheme: slate
       toggle:
         icon: material/lightbulb
         name: Switch to Light Mode
@@ -34,7 +36,8 @@ markdown_extensions:
         emoji_index: !!python/name:materialx.emoji.twemoji
         emoji_generator: !!python/name:materialx.emoji.to_svg
     - pymdownx.superfences
-    - pymdownx.tabbed
+    - pymdownx.tabbed:
+        alternate_style: true
 nav:
     - Introduction: 'index.md'
     - Installation:

+ 4 - 4
netbox/circuits/filtersets.py

@@ -98,7 +98,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = ProviderNetwork
-        fields = ['id', 'name']
+        fields = ['id', 'name', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -115,7 +115,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = CircuitType
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'description']
 
 
 class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@@ -193,7 +193,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Circuit
-        fields = ['id', 'cid', 'install_date', 'commit_rate']
+        fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -234,7 +234,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
 
     class Meta:
         model = CircuitTermination
-        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
+        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 24 - 8
netbox/circuits/tests/test_filtersets.py

@@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         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 1', slug='circuit-type-1', description='foobar1'),
+            CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
         ))
 
@@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'slug': ['circuit-type-1']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Circuit.objects.all()
@@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         ProviderNetwork.objects.bulk_create(provider_networks)
 
         circuits = (
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
+            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
+            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
             Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
             Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
             Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
@@ -241,6 +245,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Circuit.objects.bulk_create(circuits)
 
         circuit_terminations = ((
-            CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
-            CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
+            CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
+            CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
             CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
             CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
             CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
@@ -349,6 +357,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'xconnect_id': ['ABC', 'DEF']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_circuit_id(self):
         circuits = Circuit.objects.all()[:2]
         params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
@@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
         Provider.objects.bulk_create(providers)
 
         provider_networks = (
-            ProviderNetwork(name='Provider Network 1', provider=providers[0]),
-            ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+            ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
+            ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
             ProviderNetwork(name='Provider Network 3', provider=providers[2]),
         )
         ProviderNetwork.objects.bulk_create(provider_networks)
@@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'name': ['Provider Network 1', 'Provider Network 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_provider(self):
         providers = Provider.objects.all()[:2]
         params = {'provider_id': [providers[0].pk, providers[1].pk]}

+ 12 - 0
netbox/dcim/choices.py

@@ -1013,13 +1013,19 @@ class PortTypeChoices(ChoiceSet):
     TYPE_MRJ21 = 'mrj21'
     TYPE_ST = 'st'
     TYPE_SC = 'sc'
+    TYPE_SC_PC = 'sc-pc'
+    TYPE_SC_UPC = 'sc-upc'
     TYPE_SC_APC = 'sc-apc'
     TYPE_FC = 'fc'
     TYPE_LC = 'lc'
+    TYPE_LC_PC = 'lc-pc'
+    TYPE_LC_UPC = 'lc-upc'
     TYPE_LC_APC = 'lc-apc'
     TYPE_MTRJ = 'mtrj'
     TYPE_MPO = 'mpo'
     TYPE_LSH = 'lsh'
+    TYPE_LSH_PC = 'lsh-pc'
+    TYPE_LSH_UPC = 'lsh-upc'
     TYPE_LSH_APC = 'lsh-apc'
     TYPE_SPLICE = 'splice'
     TYPE_CS = 'cs'
@@ -1059,12 +1065,18 @@ class PortTypeChoices(ChoiceSet):
             (
                 (TYPE_FC, 'FC'),
                 (TYPE_LC, 'LC'),
+                (TYPE_LC_PC, 'LC/PC'),
+                (TYPE_LC_UPC, 'LC/UPC'),
                 (TYPE_LC_APC, 'LC/APC'),
                 (TYPE_LSH, 'LSH'),
+                (TYPE_LSH_PC, 'LSH/PC'),
+                (TYPE_LSH_UPC, 'LSH/UPC'),
                 (TYPE_LSH_APC, 'LSH/APC'),
                 (TYPE_MPO, 'MPO'),
                 (TYPE_MTRJ, 'MTRJ'),
                 (TYPE_SC, 'SC'),
+                (TYPE_SC_PC, 'SC/PC'),
+                (TYPE_SC_UPC, 'SC/UPC'),
                 (TYPE_SC_APC, 'SC/APC'),
                 (TYPE_ST, 'ST'),
                 (TYPE_CS, 'CS'),

+ 4 - 4
netbox/dcim/filtersets.py

@@ -142,7 +142,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         model = Site
         fields = [
             'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-            'contact_email',
+            'contact_email', 'description'
         ]
 
     def search(self, queryset, name, value):
@@ -237,7 +237,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = RackRole
-        fields = ['id', 'name', 'slug', 'color']
+        fields = ['id', 'name', 'slug', 'color', 'description']
 
 
 class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@@ -385,7 +385,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = RackReservation
-        fields = ['id', 'created']
+        fields = ['id', 'created', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -586,7 +586,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = DeviceRole
-        fields = ['id', 'name', 'slug', 'color', 'vm_role']
+        fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
 
 
 class PlatformFilterSet(OrganizationalModelFilterSet):

+ 13 - 0
netbox/dcim/forms/bulk_import.py

@@ -605,6 +605,19 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
             'rf_channel_width', 'tx_power',
         )
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit interface choices for parent, bridge and lag to device only
+            params = {}
+            if data.get('device'):
+                params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
+            if params:
+                self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
+                self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
+                self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
+
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data
         if 'enabled' not in self.data:

+ 5 - 4
netbox/dcim/models/devices.py

@@ -670,10 +670,11 @@ class Device(PrimaryModel, ConfigContextModel):
             })
 
         # Prevent 0U devices from being assigned to a specific position
-        if self.position and self.device_type.u_height == 0:
-            raise ValidationError({
-                'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
-            })
+        if hasattr(self, 'device_type'):
+            if self.position and self.device_type.u_height == 0:
+                raise ValidationError({
+                    'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
+                })
 
         if self.rack:
 

+ 6 - 2
netbox/dcim/tables/sites.py

@@ -85,6 +85,10 @@ class SiteTable(BaseTable):
         accessor=tables.A('asns__count'),
         viewname='ipam:asn_list',
         url_params={'site_id': 'pk'},
+        verbose_name='ASN Count'
+    )
+    asns = tables.ManyToManyColumn(
+        linkify_item=True,
         verbose_name='ASNs'
     )
     tenant = TenantColumn()
@@ -96,8 +100,8 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Site
         fields = (
-            'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
-            'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
+            'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
+            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
             'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

+ 24 - 8
netbox/dcim/tests/test_filtersets.py

@@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
         ASN.objects.bulk_create(asns)
 
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
-            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com', description='foobar1'),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com', description='foobar2'),
             Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
         )
         Site.objects.bulk_create(sites)
@@ -201,6 +201,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'contact_email': ['contact1@example.com', 'contact2@example.com']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_status(self):
         params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -329,8 +333,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         rack_roles = (
-            RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
-            RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
+            RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
+            RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00', description='foobar2'),
             RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
         )
         RackRole.objects.bulk_create(rack_roles)
@@ -347,6 +351,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'color': ['ff0000', '00ff00']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Rack.objects.all()
@@ -570,8 +578,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         reservations = (
-            RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]),
-            RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]),
+            RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
+            RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
             RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
         )
         RackReservation.objects.bulk_create(reservations)
@@ -604,6 +612,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_tenant_group(self):
         tenant_groups = TenantGroup.objects.all()[:2]
         params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
@@ -1088,8 +1100,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         device_roles = (
-            DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True),
-            DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True),
+            DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
             DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
         )
         DeviceRole.objects.bulk_create(device_roles)
@@ -1112,6 +1124,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'vm_role': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Platform.objects.all()

+ 5 - 4
netbox/extras/filtersets.py

@@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
 
     class Meta:
         model = CustomField
-        fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
+        fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -103,7 +103,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
     class Meta:
         model = ExportTemplate
-        fields = ['id', 'content_type', 'name']
+        fields = ['id', 'content_type', 'name', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -177,14 +177,15 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
 
     class Meta:
         model = Tag
-        fields = ['id', 'name', 'slug', 'color']
+        fields = ['id', 'name', 'slug', 'color', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(slug__icontains=value)
+            Q(slug__icontains=value) |
+            Q(description__icontains=value)
         )
 
     def _content_type(self, queryset, name, values):

+ 12 - 4
netbox/extras/tests/test_filtersets.py

@@ -153,8 +153,8 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
 
         export_templates = (
-            ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'),
-            ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'),
+            ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'),
+            ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'),
             ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'),
         )
         ExportTemplate.objects.bulk_create(export_templates)
@@ -167,6 +167,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         params = {'content_type': ContentType.objects.get(model='site').pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
     queryset = ImageAttachment.objects.all()
@@ -542,8 +546,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         tags = (
-            Tag(name='Tag 1', slug='tag-1', color='ff0000'),
-            Tag(name='Tag 2', slug='tag-2', color='00ff00'),
+            Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
+            Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
             Tag(name='Tag 3', slug='tag-3', color='0000ff'),
         )
         Tag.objects.bulk_create(tags)
@@ -567,6 +571,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'color': ['ff0000', '00ff00']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_content_type(self):
         params = {'content_type': ['dcim.site', 'circuits.provider']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 1 - 1
netbox/ipam/api/serializers.py

@@ -126,7 +126,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
 
 
 class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
     group = NestedFHRPGroupSerializer()
     interface_type = ContentTypeField(
         queryset=ContentType.objects.all()

+ 2 - 0
netbox/ipam/choices.py

@@ -189,8 +189,10 @@ class ServiceProtocolChoices(ChoiceSet):
 
     PROTOCOL_TCP = 'tcp'
     PROTOCOL_UDP = 'udp'
+    PROTOCOL_SCTP = 'sctp'
 
     CHOICES = (
         (PROTOCOL_TCP, 'TCP'),
         (PROTOCOL_UDP, 'UDP'),
+        (PROTOCOL_SCTP, 'SCTP'),
     )

+ 9 - 9
netbox/ipam/filtersets.py

@@ -75,7 +75,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique']
+        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
 
 
 class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@@ -117,7 +117,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = RouteTarget
-        fields = ['id', 'name']
+        fields = ['id', 'name', 'description']
 
 
 class RIRFilterSet(OrganizationalModelFilterSet):
@@ -155,7 +155,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Aggregate
-        fields = ['id', 'date_added']
+        fields = ['id', 'date_added', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -203,7 +203,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = ASN
-        fields = ['id', 'asn']
+        fields = ['id', 'asn', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -225,7 +225,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = Role
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'description']
 
 
 class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@@ -354,7 +354,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Prefix
-        fields = ['id', 'is_pool', 'mark_utilized']
+        fields = ['id', 'is_pool', 'mark_utilized', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -460,7 +460,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
 
     class Meta:
         model = IPRange
-        fields = ['id']
+        fields = ['id', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -839,7 +839,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = VLAN
-        fields = ['id', 'vid', 'name']
+        fields = ['id', 'vid', 'name', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -891,7 +891,7 @@ class ServiceFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = Service
-        fields = ['id', 'name', 'protocol']
+        fields = ['id', 'name', 'protocol', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 4 - 4
netbox/ipam/tables/fhrp.py

@@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable):
         orderable=False,
         verbose_name='IP Addresses'
     )
-    interface_count = tables.Column(
-        verbose_name='Interfaces'
+    member_count = tables.Column(
+        verbose_name='Members'
     )
     tags = TagColumn(
         url_name='ipam:fhrpgroup_list'
@@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = FHRPGroup
         fields = (
-            'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
+            'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count',
             'tags', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
+        default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count')
 
 
 class FHRPGroupAssignmentTable(BaseTable):

+ 5 - 1
netbox/ipam/tables/ip.py

@@ -117,6 +117,10 @@ class ASNTable(BaseTable):
     site_count = LinkedCountColumn(
         viewname='dcim:site_list',
         url_params={'asn_id': 'pk'},
+        verbose_name='Site Count'
+    )
+    sites = tables.ManyToManyColumn(
+        linkify_item=True,
         verbose_name='Sites'
     )
     tenant = TenantColumn()
@@ -129,7 +133,7 @@ class ASNTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ASN
         fields = (
-            'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created',
+            'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'actions', 'created',
             'last_updated', 'tags',
         )
         default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')

+ 54 - 18
netbox/ipam/tests/test_filtersets.py

@@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         ]
 
         asns = (
-            ASN(asn=64512, rir=rirs[0], tenant=tenants[0]),
-            ASN(asn=64513, rir=rirs[0], tenant=tenants[0]),
+            ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
+            ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
             ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
             ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
             ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
@@ -86,6 +86,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VRF.objects.all()
@@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         vrfs = (
-            VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False),
-            VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False),
+            VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False, description='foobar1'),
+            VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False, description='foobar2'),
             VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
             VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
             VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
@@ -174,6 +178,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RouteTarget.objects.all()
@@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         route_targets = (
-            RouteTarget(name='65000:1001', tenant=tenants[0]),
-            RouteTarget(name='65000:1002', tenant=tenants[0]),
+            RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
+            RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
             RouteTarget(name='65000:1003', tenant=tenants[0]),
             RouteTarget(name='65000:1004', tenant=tenants[0]),
             RouteTarget(name='65000:2001', tenant=tenants[1]),
@@ -256,6 +264,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RIR.objects.all()
@@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         aggregates = (
-            Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'),
-            Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'),
+            Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01', description='foobar1'),
+            Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02', description='foobar2'),
             Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'),
             Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'),
             Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'),
@@ -340,6 +352,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'date_added': ['2020-01-01', '2020-01-02']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     # TODO: Test for multiple values
     def test_prefix(self):
         params = {'prefix': '10.1.0.0/16'}
@@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         roles = (
-            Role(name='Role 1', slug='role-1'),
-            Role(name='Role 2', slug='role-2'),
+            Role(name='Role 1', slug='role-1', description='foobar1'),
+            Role(name='Role 2', slug='role-2', description='foobar2'),
             Role(name='Role 3', slug='role-3'),
         )
         Role.objects.bulk_create(roles)
@@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'slug': ['role-1', 'role-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Prefix.objects.all()
@@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         prefixes = (
-            Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
-            Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
+            Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
+            Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
             Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
             Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
             Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
@@ -601,6 +621,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPRange.objects.all()
@@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         ip_ranges = (
-            IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
-            IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE),
+            IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
+            IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
             IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
             IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
             IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
@@ -692,6 +716,10 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
@@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 
         vlans = (
             # Create one VLAN per VLANGroup
-            VLAN(vid=1, name='Region 1', group=groups[0]),
-            VLAN(vid=2, name='Region 2', group=groups[1]),
+            VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
+            VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
             VLAN(vid=3, name='Region 3', group=groups[2]),
             VLAN(vid=4, name='Site Group 1', group=groups[3]),
             VLAN(vid=5, name='Site Group 2', group=groups[4]),
@@ -1271,6 +1299,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'group': [groups[0].slug, groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_role(self):
         roles = Role.objects.all()[:2]
         params = {'role_id': [roles[0].pk, roles[1].pk]}
@@ -1337,8 +1369,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         VirtualMachine.objects.bulk_create(virtual_machines)
 
         services = (
-            Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
-            Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
+            Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001], description='foobar1'),
+            Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002], description='foobar2'),
             Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
             Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
             Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
@@ -1354,6 +1386,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_port(self):
         params = {'port': '1001'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

+ 0 - 1
netbox/ipam/views.py

@@ -1038,7 +1038,6 @@ class ServiceListView(generic.ObjectListView):
     filterset = filtersets.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
-    action_buttons = ('import', 'export')
 
 
 class ServiceView(generic.ObjectView):

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 #
 
-VERSION = '3.1.8'
+VERSION = '3.1.9'
 
 # Hostname
 HOSTNAME = platform.node()

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 27 - 6
netbox/project-static/src/forms/scopeSelector.ts

@@ -6,7 +6,16 @@ type ShowHideMap = {
    *
    * @example vlangroup_edit
    */
-  [view: string]: {
+  [view: string]: string;
+};
+
+type ShowHideLayout = {
+  /**
+   * Name of layout config
+   *
+   * @example vlangroup
+   */
+  [config: string]: {
     /**
      * Default layout.
      */
@@ -19,15 +28,15 @@ type ShowHideMap = {
 };
 
 /**
- * Mapping of scope names to arrays of object types whose fields should be hidden or shown when
+ * Mapping of layout names to arrays of object types whose fields should be hidden or shown when
  * the scope type (key) is selected.
  *
  * For example, if `region` is the scope type, the fields with IDs listed in
  * showHideMap.region.hide should be hidden, and the fields with IDs listed in
  * showHideMap.region.show should be shown.
  */
-const showHideMap: ShowHideMap = {
-  vlangroup_edit: {
+const showHideLayout: ShowHideLayout = {
+  vlangroup: {
     region: {
       hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
       show: ['id_region'],
@@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
     },
   },
 };
+
+/**
+ * Mapping of view names to layout configurations
+ *
+ * For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
+ */
+const showHideMap: ShowHideMap = {
+  vlangroup_add: 'vlangroup',
+  vlangroup_edit: 'vlangroup',
+};
+
 /**
  * Toggle visibility of a given element's parent.
  * @param query CSS Query.
@@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
 function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
   // Scope type's innerText looks something like `DCIM > region`.
   const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
+  const layoutConfig = showHideMap[view];
 
-  for (const [scope, fields] of Object.entries(showHideMap[view])) {
+  for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
     // If the scope type ends with the specified scope, toggle its field visibility according to
     // the show/hide values.
     if (scopeType.endsWith(scope)) {
@@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
       break;
     } else {
       // Otherwise, hide all fields.
-      for (const field of showHideMap[view].default.hide) {
+      for (const field of showHideLayout[layoutConfig].default.hide) {
         toggleParentVisibility(`#${field}`, 'hide');
       }
     }

+ 2 - 3
netbox/project-static/styles/theme-dark.scss

@@ -23,7 +23,6 @@ $theme-colors: (
   'danger': $danger,
   'light': $light,
   'dark': $dark,
-
   // General-purpose palette
   'blue': $blue-300,
   'indigo': $indigo-300,
@@ -37,7 +36,7 @@ $theme-colors: (
   'cyan': $cyan-300,
   'gray': $gray-300,
   'black': $black,
-  'white': $white,
+  'white': $white
 );
 
 // Gradient
@@ -146,7 +145,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
 $nav-pills-link-active-color: $component-active-color;
 $nav-pills-link-active-bg: $component-active-bg;
 
-$navbar-light-color: $darkest;
+$navbar-light-color: $navbar-dark-color;
 $navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
 $navbar-light-toggler-border-color: $gray-700;
 

+ 6 - 2
netbox/templates/base/base.html

@@ -139,7 +139,7 @@
 
   <body>
     <script type="text/javascript">
-      (function() {
+      function checkSideNav() {
         // Check localStorage to see if the sidebar should be pinned.
         var sideNavRaw = localStorage.getItem('netbox-sidenav');
         // Determine if the device has a small screeen. This media query is equivalent to
@@ -154,11 +154,15 @@
             // jumpy/glitchy behavior on page reloads.
             document.body.setAttribute('data-sidenav-pinned', '');
             document.body.setAttribute('data-sidenav-show', '');
+            document.body.removeAttribute('data-sidenav-hidden');
           } else {
+            document.body.removeAttribute('data-sidenav-pinned');
             document.body.setAttribute('data-sidenav-hidden', '');
           }
         }
-      })();
+      }
+      window.addEventListener('resize', function(){ checkSideNav() });
+      checkSideNav();
     </script>
 
     {# Page layout #}

+ 48 - 46
netbox/templates/base/layout.html

@@ -108,56 +108,58 @@
 
         {# Page footer #}
         <footer class="footer container-fluid">
-          <div class="row align-items-center justify-content-between mx-0">
-
-            {# Docs & Community Links #}
-            <div class="col-sm-12 col-md-auto fs-4 noprint">
-              <nav class="nav justify-content-center justify-content-lg-start">
-                {# Documentation #}
-                <a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
-                  <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                </a>
-
-                {# REST API #}
-                <a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
-                  <i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                </a>
-
-                {# API docs #}
-                <a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
-                  <i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                </a>
-
-                {# GraphQL API #}
-                {% if config.GRAPHQL_ENABLED %}
-                  <a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
-                    <i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                  </a>
-                {% endif %}
-
-                {# GitHub #}
-                <a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
-                  <i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                </a>
-
-                {# NetDev Slack #}
-                <a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
-                  <i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
-                </a>
-              </nav>
-            </div>
-
-            {# System Info #}
-            <div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
-              <span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
-              <span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
+          {% block footer %}
+            <div class="row align-items-center justify-content-between mx-0">
+                  
+              <div class="col-sm-12 col-md-auto fs-4 noprint">
+                <nav class="nav justify-content-center justify-content-lg-start">
+                  {% block footer_links %}
+                    {# Documentation #}
+                    <a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
+                      <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+    
+                    {# REST API #}
+                    <a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
+                      <i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+    
+                    {# API docs #}
+                    <a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
+                      <i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+    
+                    {# GraphQL API #}
+                    {% if config.GRAPHQL_ENABLED %}
+                    <a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
+                      <i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+                    {% endif %}
+    
+                    {# GitHub #}
+                    <a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
+                      <i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+    
+                    {# NetDev Slack #}
+                    <a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
+                      <i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                    </a>
+                  {% endblock footer_links %}
+                </nav>
+              </div>
+    
+              <div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
+                <span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
+                <span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
+              </div>
+                  
             </div>
-
-          </div>
+          {% endblock footer %}
         </footer>
 
       </div>
 
     </main>
   </div>
-{% endblock layout %}
+{% endblock layout %}

+ 91 - 35
netbox/templates/dcim/site.html

@@ -183,42 +183,98 @@
     </div>
     <div class="col col-md-6">
       <div class="card">
-        <h5 class="card-header">Stats</h5>
+        <h5 class="card-header">Related Objects</h5>
         <div class="card-body">
-          <div class="row">
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}" class="btn {% if stats.location_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.location_count }}</a></h2>
-              <p>Locations</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
-              <p>Racks</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
-              <p>Devices</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}" class="btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
-              <p>Prefixes</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
-              <p>VLANs</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
-              <p>Circuits</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vm_count }}</a></h2>
-              <p>Virtual Machines</p>
-            </div>
-            <div class="col col-md-4 text-center">
-              <h2><a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}" class="btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
-              <p>ASNs</p>
-            </div>
-          </div>
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Locations</th>
+              <td class="text-end">
+                {% if stats.location_count %}
+                  <a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}">{{ stats.location_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Racks</th>
+              <td class="text-end">
+                {% if stats.rack_count %}
+                  <div class="dropdown">
+                    <button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+                      {{ stats.rack_count }}
+                    </button>
+                    <ul class="dropdown-menu">
+                      <li><a class="dropdown-item" href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}">View Racks</a></li>
+                      <li><a class="dropdown-item" href="{% url 'dcim:rack_elevation_list' %}?site_id={{ object.pk }}">View Elevations</a></li>
+                    </ul>
+                  </div>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Devices</th>
+              <td class="text-end">
+                {% if stats.device_count %}
+                  <a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}">{{ stats.device_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Virtual Machines</th>
+              <td class="text-end">
+                {% if stats.vm_count %}
+                  <a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}">{{ stats.vm_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Prefixes</th>
+              <td class="text-end">
+                {% if stats.prefix_count %}
+                  <a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}">{{ stats.prefix_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">VLANs</th>
+              <td class="text-end">
+                {% if stats.vlan_count %}
+                  <a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}">{{ stats.vlan_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">ASNs</th>
+              <td class="text-end">
+                {% if stats.asn_count %}
+                  <a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}">{{ stats.asn_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Circuits</th>
+              <td class="text-end">
+                {% if stats.circuit_count %}
+                <a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}">{{ stats.circuit_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+          </table>
         </div>
       </div>
       {% include 'inc/panels/contacts.html' %}

+ 2 - 2
netbox/templates/ipam/aggregate/prefixes.html

@@ -25,12 +25,12 @@
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
         {% if perms.ipam.change_prefix %}
-          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
+          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% endif %}
         {% if perms.ipam.delete_prefix %}
-          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
+          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
         {% endif %}

+ 14 - 2
netbox/templates/search.html

@@ -5,7 +5,18 @@
 
 {% block title %}Search{% endblock %}
 
-{% block content %}
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <button class="nav-link active" type="button" role="tab">
+        Results
+      </button>
+    </li>
+  </ul>
+{% endblock tabs %}
+
+{% block content-wrapper %}
+  <div class="tab-content">
     {% if request.GET.q %}
         {% if results %}
             <div class="row">
@@ -73,4 +84,5 @@
             </div>
         </div>
     {% endif %}
-{% endblock content %}
+  </div>
+{% endblock content-wrapper %}

+ 2 - 2
netbox/tenancy/filtersets.py

@@ -62,7 +62,7 @@ class TenantFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = Tenant
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -131,7 +131,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = ContactRole
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'description']
 
 
 class ContactFilterSet(PrimaryModelFilterSet):

+ 12 - 4
netbox/tenancy/tests/test_filtersets.py

@@ -64,8 +64,8 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
             tenantgroup.save()
 
         tenants = (
-            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
-            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0], description='foobar1'),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1], description='foobar2'),
             Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
         )
         Tenant.objects.bulk_create(tenants)
@@ -85,6 +85,10 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'group': [group[0].slug, group[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ContactGroup.objects.all()
@@ -137,8 +141,8 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         contact_roles = (
-            ContactRole(name='Contact Role 1', slug='contact-role-1'),
-            ContactRole(name='Contact Role 2', slug='contact-role-2'),
+            ContactRole(name='Contact Role 1', slug='contact-role-1', description='foobar1'),
+            ContactRole(name='Contact Role 2', slug='contact-role-2', description='foobar2'),
             ContactRole(name='Contact Role 3', slug='contact-role-3'),
         )
         ContactRole.objects.bulk_create(contact_roles)
@@ -151,6 +155,10 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'slug': ['contact-role-1', 'contact-role-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Contact.objects.all()

+ 2 - 2
netbox/users/filtersets.py

@@ -97,7 +97,7 @@ class TokenFilterSet(BaseFilterSet):
 
     class Meta:
         model = Token
-        fields = ['id', 'key', 'write_enabled']
+        fields = ['id', 'key', 'write_enabled', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -138,7 +138,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
 
     class Meta:
         model = ObjectPermission
-        fields = ['id', 'name', 'enabled', 'object_types']
+        fields = ['id', 'name', 'enabled', 'object_types', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 12 - 4
netbox/users/tests/test_filtersets.py

@@ -142,8 +142,8 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         )
 
         permissions = (
-            ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete']),
-            ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete']),
+            ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete'], description='foobar1'),
+            ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete'], description='foobar2'),
             ObjectPermission(name='Permission 3', actions=['view', 'add', 'change', 'delete']),
             ObjectPermission(name='Permission 4', actions=['view'], enabled=False),
             ObjectPermission(name='Permission 5', actions=['add'], enabled=False),
@@ -183,6 +183,10 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         params = {'object_types': [object_types[0].pk, object_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class TokenTestCase(TestCase, BaseFilterSetTests):
     queryset = Token.objects.all()
@@ -201,8 +205,8 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
         future_date = make_aware(datetime.datetime(3000, 1, 1))
         past_date = make_aware(datetime.datetime(2000, 1, 1))
         tokens = (
-            Token(user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True),
-            Token(user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True),
+            Token(user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1'),
+            Token(user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2'),
             Token(user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False),
         )
         Token.objects.bulk_create(tokens)
@@ -232,3 +236,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'write_enabled': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 3 - 1
netbox/utilities/forms/forms.py

@@ -98,7 +98,9 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
     An extendable form to be used for renaming objects in bulk.
     """
     find = forms.CharField()
-    replace = forms.CharField()
+    replace = forms.CharField(
+        required=False
+    )
     use_regex = forms.BooleanField(
         required=False,
         initial=True,

+ 6 - 0
netbox/utilities/querysets.py

@@ -39,6 +39,12 @@ class RestrictedQuerySet(QuerySet):
                     # Any permission with null constraints grants access to _all_ instances
                     attrs = Q()
                     break
+            else:
+                # for else, when no break
+                # avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
+                # DISTINCT acts globally on the entire request, which may not be desirable.
+                allowed_objects = self.model.objects.filter(attrs)
+                attrs = Q(pk__in=allowed_objects)
             qs = self.filter(attrs)
 
         return qs

+ 1 - 1
netbox/virtualization/filtersets.py

@@ -282,7 +282,7 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = VMInterface
-        fields = ['id', 'name', 'enabled', 'mtu']
+        fields = ['id', 'name', 'enabled', 'mtu', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 6 - 2
netbox/virtualization/tests/test_filtersets.py

@@ -422,8 +422,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         VirtualMachine.objects.bulk_create(vms)
 
         interfaces = (
-            VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
-            VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
+            VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', description='foobar1'),
+            VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', description='foobar2'),
             VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
         )
         VMInterface.objects.bulk_create(interfaces)
@@ -478,3 +478,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_mac_address(self):
         params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 2 - 2
netbox/wireless/filtersets.py

@@ -61,7 +61,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = WirelessLAN
-        fields = ['id', 'ssid', 'auth_psk']
+        fields = ['id', 'ssid', 'auth_psk', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -93,7 +93,7 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet):
 
     class Meta:
         model = WirelessLink
-        fields = ['id', 'ssid', 'auth_psk']
+        fields = ['id', 'ssid', 'auth_psk', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 14 - 4
netbox/wireless/tests/test_filtersets.py

@@ -25,8 +25,8 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
             group.save()
 
         child_groups = (
-            WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]),
-            WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]),
+            WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0], description='foobar1'),
+            WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0], description='foobar2'),
             WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]),
             WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]),
             WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]),
@@ -54,6 +54,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = WirelessLAN.objects.all()
@@ -147,7 +151,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
             status=LinkStatusChoices.STATUS_CONNECTED,
             auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
             auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
-            auth_psk='PSK1'
+            auth_psk='PSK1',
+            description='foobar1'
         ).save()
         WirelessLink(
             interface_a=interfaces[1],
@@ -156,7 +161,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
             status=LinkStatusChoices.STATUS_PLANNED,
             auth_type=WirelessAuthTypeChoices.TYPE_WEP,
             auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
-            auth_psk='PSK2'
+            auth_psk='PSK2',
+            description='foobar2'
         ).save()
         WirelessLink(
             interface_a=interfaces[4],
@@ -192,3 +198,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_auth_psk(self):
         params = {'auth_psk': ['PSK1', 'PSK2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 2 - 2
requirements.txt

@@ -10,7 +10,7 @@ django-redis==5.2.0
 django-rq==2.5.1
 django-tables2==2.4.1
 django-taggit==2.1.0
-django-timezone-field==4.2.3
+django-timezone-field==5.0
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 graphene_django==2.15.0
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Markdown==3.3.6
 markdown-include==0.6.0
-mkdocs-material==8.1.11
+mkdocs-material==8.2.5
 netaddr==0.8.0
 Pillow==9.0.1
 psycopg2-binary==2.9.3

+ 17 - 0
upgrade.sh

@@ -10,6 +10,23 @@ cd "$(dirname "$0")"
 VIRTUALENV="$(pwd -P)/venv"
 PYTHON="${PYTHON:-python3}"
 
+# Validate the minimum required Python version
+COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 7) else 0)'"
+PYTHON_VERSION=$(eval "${PYTHON} -V")
+eval $COMMAND || {
+  echo "--------------------------------------------------------------------"
+  echo "ERROR: Unsupported Python version: ${PYTHON_VERSION}. NetBox requires"
+  echo "Python 3.7 or later. To specify an alternate Python executable, set"
+  echo "the PYTHON environment variable. For example:"
+  echo ""
+  echo "  sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh"
+  echo ""
+  echo "To show your current Python version: ${PYTHON} -V"
+  echo "--------------------------------------------------------------------"
+  exit 1
+}
+echo "Using ${PYTHON_VERSION}"
+
 # Remove the existing virtual environment (if any)
 if [ -d "$VIRTUALENV" ]; then
   COMMAND="rm -rf ${VIRTUALENV}"

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