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 460 additions and 193 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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