merge develop into issue

This commit is contained in:
Alex Gittings 2022-03-09 17:47:58 +00:00
commit ef6576bdd6
42 changed files with 469 additions and 202 deletions

View File

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

View File

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

View File

@ -2,6 +2,8 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div> </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) ![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 NetBox is an infrastructure resource modeling (IRM) tool designed to empower

View File

@ -1,5 +1,7 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![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? # 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: 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:

View File

@ -1,13 +1,35 @@
# NetBox v3.1 # 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 ### Bug Fixes
* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces * [#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 * [#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 * [#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 * [#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
--- ---

View File

@ -8,11 +8,13 @@ theme:
icon: icon:
repo: fontawesome/brands/github repo: fontawesome/brands/github
palette: palette:
- scheme: default - media: "(prefers-color-scheme: light)"
scheme: default
toggle: toggle:
icon: material/lightbulb-outline icon: material/lightbulb-outline
name: Switch to Dark Mode name: Switch to Dark Mode
- scheme: slate - media: "(prefers-color-scheme: dark)"
scheme: slate
toggle: toggle:
icon: material/lightbulb icon: material/lightbulb
name: Switch to Light Mode name: Switch to Light Mode

View File

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

View File

@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
CircuitType.objects.bulk_create(( CircuitType.objects.bulk_create((
CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 1', slug='circuit-type-1', description='foobar1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'), CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)) ))
@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['circuit-type-1']} params = {'slug': ['circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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): class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
circuits = ( 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 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), 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[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[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), 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]} params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]} params = {'region_id': [regions[0].pk, regions[1].pk]}
@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = (( 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[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'), 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[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[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'), 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']} params = {'xconnect_id': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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_circuit_id(self): def test_circuit_id(self):
circuits = Circuit.objects.all()[:2] circuits = Circuit.objects.all()[:2]
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]} params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
provider_networks = ( provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
ProviderNetwork(name='Provider Network 2', provider=providers[1]), ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
ProviderNetwork(name='Provider Network 3', provider=providers[2]), ProviderNetwork(name='Provider Network 3', provider=providers[2]),
) )
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['Provider Network 1', 'Provider Network 2']} params = {'name': ['Provider Network 1', 'Provider Network 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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): def test_provider(self):
providers = Provider.objects.all()[:2] providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]} params = {'provider_id': [providers[0].pk, providers[1].pk]}

View File

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

View File

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

View File

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

View File

@ -91,6 +91,10 @@ class SiteTable(BaseTable):
accessor=tables.A('asns__count'), accessor=tables.A('asns__count'),
viewname='ipam:asn_list', viewname='ipam:asn_list',
url_params={'site_id': 'pk'}, url_params={'site_id': 'pk'},
verbose_name='ASN Count'
)
asns = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs' verbose_name='ASNs'
) )
tenant = TenantColumn() tenant = TenantColumn()
@ -105,9 +109,15 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Site model = Site
fields = ( fields = (
<<<<<<< HEAD
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated', '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') default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

View File

@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
sites = ( 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 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'), 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(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) Site.objects.bulk_create(sites)
@ -201,6 +201,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'contact_email': ['contact1@example.com', 'contact2@example.com']} params = {'contact_email': ['contact1@example.com', 'contact2@example.com']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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_status(self): def test_status(self):
params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]} params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -329,8 +333,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
rack_roles = ( rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'), RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'), 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(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
) )
RackRole.objects.bulk_create(rack_roles) RackRole.objects.bulk_create(rack_roles)
@ -347,6 +351,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']} params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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 RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@ -570,8 +578,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
reservations = ( reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]), 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]), 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(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
) )
RackReservation.objects.bulk_create(reservations) RackReservation.objects.bulk_create(reservations)
@ -604,6 +612,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant': [tenants[0].slug, tenants[1].slug]} params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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_tenant_group(self): def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2] tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
@ -1088,8 +1100,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
device_roles = ( device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', 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), 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(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
) )
DeviceRole.objects.bulk_create(device_roles) DeviceRole.objects.bulk_create(device_roles)
@ -1112,6 +1124,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vm_role': 'false'} params = {'vm_role': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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 PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Platform.objects.all() queryset = Platform.objects.all()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable):
orderable=False, orderable=False,
verbose_name='IP Addresses' verbose_name='IP Addresses'
) )
interface_count = tables.Column( member_count = tables.Column(
verbose_name='Interfaces' verbose_name='Members'
) )
tags = TagColumn( tags = TagColumn(
url_name='ipam:fhrpgroup_list' url_name='ipam:fhrpgroup_list'
@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = FHRPGroup model = FHRPGroup
fields = ( 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', '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): class FHRPGroupAssignmentTable(BaseTable):

View File

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

View File

@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
] ]
asns = ( asns = (
ASN(asn=64512, 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]), ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), 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]} params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) 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): class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VRF.objects.all() queryset = VRF.objects.all()
@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
vrfs = ( vrfs = (
VRF(name='VRF 1', rd='65000:100', 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), 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 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 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], 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]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RouteTarget.objects.all() queryset = RouteTarget.objects.all()
@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
route_targets = ( route_targets = (
RouteTarget(name='65000:1001', tenant=tenants[0]), RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
RouteTarget(name='65000:1002', tenant=tenants[0]), RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
RouteTarget(name='65000:1003', tenant=tenants[0]), RouteTarget(name='65000:1003', tenant=tenants[0]),
RouteTarget(name='65000:1004', tenant=tenants[0]), RouteTarget(name='65000:1004', tenant=tenants[0]),
RouteTarget(name='65000:2001', tenant=tenants[1]), 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]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) 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): class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RIR.objects.all() queryset = RIR.objects.all()
@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
aggregates = ( aggregates = (
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'), 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'), 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='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: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'), 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']} params = {'date_added': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
# TODO: Test for multiple values # TODO: Test for multiple values
def test_prefix(self): def test_prefix(self):
params = {'prefix': '10.1.0.0/16'} params = {'prefix': '10.1.0.0/16'}
@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
roles = ( roles = (
Role(name='Role 1', slug='role-1'), Role(name='Role 1', slug='role-1', description='foobar1'),
Role(name='Role 2', slug='role-2'), Role(name='Role 2', slug='role-2', description='foobar2'),
Role(name='Role 3', slug='role-3'), Role(name='Role 3', slug='role-3'),
) )
Role.objects.bulk_create(roles) Role.objects.bulk_create(roles)
@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['role-1', 'role-2']} params = {'slug': ['role-1', 'role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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): class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
prefixes = ( 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.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]), 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.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='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), 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]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPRange.objects.all() queryset = IPRange.objects.all()
@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
ip_ranges = ( 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.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), 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.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='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), 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]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vlans = ( vlans = (
# Create one VLAN per VLANGroup # Create one VLAN per VLANGroup
VLAN(vid=1, name='Region 1', group=groups[0]), VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
VLAN(vid=2, name='Region 2', group=groups[1]), VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
VLAN(vid=3, name='Region 3', group=groups[2]), VLAN(vid=3, name='Region 3', group=groups[2]),
VLAN(vid=4, name='Site Group 1', group=groups[3]), VLAN(vid=4, name='Site Group 1', group=groups[3]),
VLAN(vid=5, name='Site Group 2', group=groups[4]), 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]} params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_role(self):
roles = Role.objects.all()[:2] roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]} params = {'role_id': [roles[0].pk, roles[1].pk]}
@ -1337,8 +1369,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), 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]), 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(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[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]), 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} params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_port(self):
params = {'port': '1001'} params = {'port': '1001'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -23,7 +23,6 @@ $theme-colors: (
'danger': $danger, 'danger': $danger,
'light': $light, 'light': $light,
'dark': $dark, 'dark': $dark,
// General-purpose palette // General-purpose palette
'blue': $blue-300, 'blue': $blue-300,
'indigo': $indigo-300, 'indigo': $indigo-300,
@ -37,7 +36,7 @@ $theme-colors: (
'cyan': $cyan-300, 'cyan': $cyan-300,
'gray': $gray-300, 'gray': $gray-300,
'black': $black, 'black': $black,
'white': $white, 'white': $white
); );
// Gradient // 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-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg; $nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $darkest; $navbar-light-color: $darker;
$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; $navbar-light-toggler-border-color: $gray-700;
$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-toggler-border-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
// Dropdowns // Dropdowns
$dropdown-color: $body-color; $dropdown-color: $body-color;

View File

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

View File

@ -108,52 +108,54 @@
{# Page footer #} {# Page footer #}
<footer class="footer container-fluid"> <footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0"> {% block footer %}
<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">
<div class="col-sm-12 col-md-auto fs-4 noprint"> <nav class="nav justify-content-center justify-content-lg-start">
<nav class="nav justify-content-center justify-content-lg-start"> {% block footer_links %}
{# Documentation #} {# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank"> <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> <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </a>
{# REST API #} {# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank"> <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> <i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </a>
{# API docs #} {# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank"> <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> <i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </a>
{# GraphQL API #} {# GraphQL API #}
{% if config.GRAPHQL_ENABLED %} {% if config.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank"> <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> <i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </a>
{% endif %} {% endif %}
{# GitHub #} {# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank"> <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> <i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </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>
{# 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> </div>
{% endblock footer %}
{# 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>
</div>
</div>
</footer> </footer>
</div> </div>

View File

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

View File

@ -5,7 +5,18 @@
{% block title %}Search{% endblock %} {% 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 request.GET.q %}
{% if results %} {% if results %}
<div class="row"> <div class="row">
@ -73,4 +84,5 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endblock content %} </div>
{% endblock content-wrapper %}

View File

@ -46,7 +46,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = ContactRole model = ContactRole
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug', 'description']
class ContactFilterSet(PrimaryModelFilterSet): class ContactFilterSet(PrimaryModelFilterSet):

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,12 @@ class RestrictedQuerySet(QuerySet):
# Any permission with null constraints grants access to _all_ instances # Any permission with null constraints grants access to _all_ instances
attrs = Q() attrs = Q()
break 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) qs = self.filter(attrs)
return qs return qs

View File

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

View File

@ -422,8 +422,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(vms) VirtualMachine.objects.bulk_create(vms)
interfaces = ( 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[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'), 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(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
) )
VMInterface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(interfaces)
@ -478,3 +478,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_mac_address(self): def test_mac_address(self):
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} 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) 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)

View File

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

View File

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

View File

@ -10,7 +10,7 @@ django-redis==5.2.0
django-rq==2.5.1 django-rq==2.5.1
django-tables2==2.4.1 django-tables2==2.4.1
django-taggit==2.1.0 django-taggit==2.1.0
django-timezone-field==4.2.3 django-timezone-field==5.0
djangorestframework==3.12.4 djangorestframework==3.12.4
drf-yasg[validation]==1.20.0 drf-yasg[validation]==1.20.0
graphene_django==2.15.0 graphene_django==2.15.0
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3 Jinja2==3.0.3
Markdown==3.3.6 Markdown==3.3.6
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.1.11 mkdocs-material==8.2.5
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.0.1 Pillow==9.0.1
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
@ -27,6 +27,7 @@ social-auth-app-django==5.0.0
social-auth-core==4.2.0 social-auth-core==4.2.0
svgwrite==1.4.1 svgwrite==1.4.1
tablib==3.2.0 tablib==3.2.0
tzdata==2021.5
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0