diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ad72e0735..3af825d30 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 68256471c..f5bf198b8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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 diff --git a/README.md b/README.md index 8429cd4b3..42bf8b619 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ NetBox logo +: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 diff --git a/docs/index.md b/docs/index.md index 943f1d7ab..5742c0ab6 100644 --- a/docs/index.md +++ b/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: diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f6b1292b8..3b02b3c42 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,13 +1,35 @@ # NetBox v3.1 -## v3.1.9 (FUTURE) +## v3.1.10 (FUTURE) + +* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode + +--- + +## 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 --- diff --git a/mkdocs.yml b/mkdocs.yml index 4be5b16e7..6245f3bb2 100644 --- a/mkdocs.yml +++ b/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 diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 5e3e00301..0680dd5a1 100644 --- a/netbox/circuits/filtersets.py +++ b/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, ContactModelFilterSet): @@ -193,7 +193,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt 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(): diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 4880a8388..20416c4e6 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/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]} diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1d3b59497..8900b2d89 100644 --- a/netbox/dcim/choices.py +++ b/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'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c198335f4..504ad69ca 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -142,7 +142,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS 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, ContactModelFilterSet): @@ -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): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a2ae20319..6b8ff043d 100644 --- a/netbox/dcim/models/devices.py +++ b/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: diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 06753ff0f..5d41a37af 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -91,6 +91,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() @@ -105,9 +109,15 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( +<<<<<<< HEAD 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated', +======= + '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', +>>>>>>> develop ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ab290f791..68a4b5571 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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() diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6867e598e..9dff088ae 100644 --- a/netbox/extras/filtersets.py +++ b/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): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 975fe7d7e..c94fb4998 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/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) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index aa1d2834a..a2c47aaec 100644 --- a/netbox/ipam/api/serializers.py +++ b/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() diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 526ef07d9..647a82810 100644 --- a/netbox/ipam/choices.py +++ b/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'), ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d7e1ee47e..5342d223d 100644 --- a/netbox/ipam/filtersets.py +++ b/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(): diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index d615b6374..19f1c8c71 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/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): diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 2cd2ab6ac..f1d490463 100644 --- a/netbox/ipam/tables/ip.py +++ b/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') diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 773737dea..2f90e070a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/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) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f5501f54f..d16e00337 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.9-dev' +VERSION = '3.1.10-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 752715e7c..b929f176a 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index f388b96cf..45abd19e6 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 566476dde..c7e20fe9a 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts index e1c7014bf..14ef972f8 100644 --- a/netbox/project-static/src/forms/scopeSelector.ts +++ b/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

(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

(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'); } } diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index 2db29ad38..c0933e991 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/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,9 +145,9 @@ $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-toggler-icon-bg: url("data:image/svg+xml,"); +$navbar-light-color: $darker; $navbar-light-toggler-border-color: $gray-700; +$navbar-light-toggler-icon-bg: url("data:image/svg+xml,"); // Dropdowns $dropdown-color: $body-color; diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 6e71b3995..08ab33eff 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -139,7 +139,7 @@ {# Page layout #} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index cf3841dd2..da2d10c65 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -108,56 +108,58 @@ {# Page footer #}

-{% endblock layout %} +{% endblock layout %} \ No newline at end of file diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 2ad970301..f71105d1b 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -183,42 +183,98 @@
-
Stats
+
Related Objects
-
-
-

{{ stats.location_count }}

-

Locations

-
- -
-

{{ stats.device_count }}

-

Devices

-
-
-

{{ stats.prefix_count }}

-

Prefixes

-
- -
-

{{ stats.circuit_count }}

-

Circuits

-
-
-

{{ stats.vm_count }}

-

Virtual Machines

-
- -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Locations + {% if stats.location_count %} + {{ stats.location_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Racks + {% if stats.rack_count %} + + {% else %} + {{ ''|placeholder }} + {% endif %} +
Devices + {% if stats.device_count %} + {{ stats.device_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Virtual Machines + {% if stats.vm_count %} + {{ stats.vm_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Prefixes + {% if stats.prefix_count %} + {{ stats.prefix_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
VLANs + {% if stats.vlan_count %} + {{ stats.vlan_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
ASNs + {% if stats.asn_count %} + {{ stats.asn_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Circuits + {% if stats.circuit_count %} + {{ stats.circuit_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% include 'inc/panels/contacts.html' %} diff --git a/netbox/templates/search.html b/netbox/templates/search.html index 187b76c59..a47b48b09 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -5,7 +5,18 @@ {% block title %}Search{% endblock %} -{% block content %} +{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
{% if request.GET.q %} {% if results %}
@@ -73,4 +84,5 @@
{% endif %} -{% endblock content %} +
+{% endblock content-wrapper %} diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 0fc709eb6..fe227e95d 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -46,7 +46,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = ContactRole - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'description'] class ContactFilterSet(PrimaryModelFilterSet): diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index 86170734c..e427c90ce 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/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() diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index ad296ea25..71246956b 100644 --- a/netbox/users/filtersets.py +++ b/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(): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 84948c691..33ed7e7ba 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/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) diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 88f837b2b..b69472e6f 100644 --- a/netbox/utilities/forms/forms.py +++ b/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, diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 738b72dc3..97d2e8779 100644 --- a/netbox/utilities/querysets.py +++ b/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 diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index dadf781a3..d7fc28f6c 100644 --- a/netbox/virtualization/filtersets.py +++ b/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(): diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 8c8f6671f..0dfc483f3 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/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) diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 3fb173b1b..b52260612 100644 --- a/netbox/wireless/filtersets.py +++ b/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(): diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 50f89c4d6..5fee4fbf4 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/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) diff --git a/requirements.txt b/requirements.txt index 5489b99cd..04d053180 100644 --- a/requirements.txt +++ b/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 @@ -27,6 +27,7 @@ social-auth-app-django==5.0.0 social-auth-core==4.2.0 svgwrite==1.4.1 tablib==3.2.0 +tzdata==2021.5 # Workaround for #7401 jsonschema==3.2.0