Compare commits

...

31 Commits

Author SHA1 Message Date
Jeremy Stretch
0c0672550a Merge pull request #14633 from netbox-community/develop
Release v3.6.9
2023-12-28 14:13:25 -05:00
Jeremy Stretch
199685d98b Release v3.6.9 2023-12-28 13:58:34 -05:00
Jeremy Stretch
3ef2db81e8 Closes #14629: Add filter tests for all q and description filters 2023-12-28 13:53:16 -05:00
Jeremy Stretch
3bacee16bd Closes #14631: Ensure description filters are available on all relevant models 2023-12-28 13:53:16 -05:00
Daniel Sheppard
45c646dcec Fixes #14482 - Fix validation error when primary IP is moved (#14514)
* Fix validation when primary IP is moved.

* Fix views test

* Work on excluding assigned_objects

* Modify clean() on model and form to properly catch error

* Fix test failure

* Fix test to check for PK

* Remove model_form check
2023-12-28 13:28:05 -05:00
Jeremy Stretch
fedcbaf4c8 Fixes #14620: Permit setting device type U height to 0 during bulk edit 2023-12-28 10:06:25 -05:00
MengYX
359c0cf3a0 Fix typo in filtersets.py
fix typo which causing exception `Cannot resolve keyword 'description_icontains' into field`
2023-12-28 08:47:43 -05:00
Jeremy Stretch
46b933a5aa Merge pull request #14616 from netbox-community/develop
Release v3.6.8
2023-12-27 16:12:13 -05:00
Jeremy Stretch
07da3f6d33 Release v3.6.8 2023-12-27 16:00:16 -05:00
Jeremy Stretch
0613e8e95c Fixes #14613: Fix display of current configuration parameters 2023-12-27 15:32:11 -05:00
Jeremy Stretch
113c60a44a Fixes #13909: Ignore empty choices when populating dynamic choice fields from initial data 2023-12-27 14:32:40 -05:00
Jeremy Stretch
8a237561ef Closes #14596: Match against description field when searching for devices 2023-12-27 13:49:39 -05:00
Jeremy Stretch
cc0fc03ec3 Changelog for #11039, #11816, #12731, #13606, #13649, #13812, #14532 2023-12-27 13:45:06 -05:00
Jeremy Stretch
b955751349 Fixes #14517: Ensure reservations tab is always displayed under rack view 2023-12-27 13:42:26 -05:00
Jeremy Stretch
d6c8d1581c Closes #11039: List parent prefixes under IP range view 2023-12-27 12:53:30 -05:00
Jeremy Stretch
e6642b5f5b Fixes #11816: Detach group/site validation error from group field 2023-12-27 12:51:51 -05:00
Jeremy Stretch
a67236fc3c Fixes #13812: Record data source sync failure when run via syncdatasource command 2023-12-27 12:51:03 -05:00
Jeremy Stretch
634681a72e Fixes #13606: Fix filtering by null for multiselect custom fields 2023-12-27 12:49:31 -05:00
Jeremy Stretch
031b7540b3 Fixes #13741: Update docs to correctly reflect inventory item uniqueness requirements 2023-12-26 13:35:03 -05:00
Jeremy Stretch
43909ee33f Fixes #13649: Permit zero-length cables 2023-12-26 09:27:58 -05:00
Jeremy Stretch
99467e8f66 Fixes #12731: Support custom validation for many-to-many fields (#14516)
* WIP

* Enforce custom validators during bulk edit

* Add bulk edit M2M validation test

* Clean up tests

* Add custom validation test for bulk import

* Misc cleanup
2023-12-22 10:01:05 -05:00
Jeremy Stretch
0d08205ab1 Fixes #14532: Device/VM change record should accurately reflect when primary/OOB IP is deleted 2023-12-22 08:47:51 -05:00
Jeremy Stretch
c289dda649 Changelog for #14507, #14538, #14549, #14560, #14575 2023-12-21 16:36:24 -05:00
Daniel Sheppard
169207058f Update search to add note 2023-12-21 16:27:43 -05:00
Jeremy Stretch
e5c565cbf4 Closes #14119: Remove redundant check for to_objectchange() 2023-12-21 16:26:20 -05:00
Jeremy Stretch
f0b9008529 Fixes #14575: Fix display of the tags column under VDC table 2023-12-21 16:00:44 -05:00
Daniel Sheppard
8dfec7e2b2 Closes #14538 - Add available_at_site filter (#14541)
* Closes #14538 - Add available_at_site filter

* Add tests

* Fix tests
2023-12-21 15:40:57 -05:00
Markku Leiniö
c1cf037eaf Print NetBox version in upgrade.sh (#14547) 2023-12-21 15:13:40 -05:00
Azmodeszer
3f4a65cc5c added ! to safe characters 2023-12-21 15:10:38 -05:00
Prince Kumar
12beac4f1a fix the result of script jobs #14549 2023-12-20 15:15:02 -05:00
Jeremy Stretch
ec245b968f PRVB 2023-12-15 16:46:53 -05:00
52 changed files with 2051 additions and 370 deletions

View File

@@ -23,7 +23,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v3.6.7
placeholder: v3.6.9
validations:
required: true
- type: dropdown

View File

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

View File

@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
!!! note
NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
## Saved Filters
Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.

View File

@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
### Name
The inventory item's name. Must be unique to the parent device.
The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
### Label

View File

@@ -1,5 +1,45 @@
# NetBox v3.6
## v3.6.9 (2023-12-28)
### Enhancements
* [#14631](https://github.com/netbox-community/netbox/issues/14631) - All models can be filtered and searched by their description field (where applicable)
### Bug Fixes
* [#14482](https://github.com/netbox-community/netbox/issues/14482) - Fix validation error when attempting to move a primary IP address to a new parent object
* [#14620](https://github.com/netbox-community/netbox/issues/14620) - Permit setting device type U height to 0 during bulk edit
* [#14621](https://github.com/netbox-community/netbox/issues/14621) - Fix error when using the device search filter
---
## v3.6.8 (2023-12-27)
### Enhancements
* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
### Bug Fixes
* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
---
## v3.6.7 (2023-12-15)
### Enhancements

View File

@@ -67,13 +67,14 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta:
model = Provider
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(accounts__account__icontains=value) |
Q(accounts__name__icontains=value) |
Q(comments__icontains=value)
@@ -101,6 +102,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(account__icontains=value) |
Q(comments__icontains=value)
).distinct()

View File

@@ -25,8 +25,8 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 1', slug='provider-1', description='foobar1'),
Provider(name='Provider 2', slug='provider-2', description='foobar2'),
Provider(name='Provider 3', slug='provider-3'),
Provider(name='Provider 4', slug='provider-4'),
Provider(name='Provider 5', slug='provider-5'),
@@ -74,6 +74,10 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider 1', 'Provider 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -82,6 +86,10 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-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_asn_id(self): # ASN object assignment
asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]}
@@ -122,6 +130,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Circuit Type 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -227,6 +239,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
))
CircuitTermination.objects.bulk_create(circuit_terminations)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_cid(self):
params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -369,6 +385,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_term_side(self):
params = {'term_side': 'A'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
@@ -440,6 +460,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderNetwork.objects.bulk_create(provider_networks)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider Network 1', 'Provider Network 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -477,6 +501,10 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderAccount.objects.bulk_create(provider_accounts)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider Account 1', 'Provider Account 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -26,7 +26,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
class Meta:
model = DataSource
fields = ('id', 'name', 'enabled')
fields = ('id', 'name', 'enabled', 'description')
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand, CommandError
from core.choices import DataSourceStatusChoices
from core.models import DataSource
@@ -33,9 +34,13 @@ class Command(BaseCommand):
for i, datasource in enumerate(datasources, start=1):
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
self.stdout.flush()
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
try:
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
except Exception as e:
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
raise e
if len(options['name']) > 1:
self.stdout.write(f"Finished.")

View File

@@ -21,14 +21,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True
enabled=True,
description='foobar1'
),
DataSource(
name='Data Source 2',
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True
enabled=True,
description='foobar2'
),
DataSource(
name='Data Source 3',
@@ -40,10 +42,18 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DataSource.objects.bulk_create(data_sources)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Data Source 1', 'Data Source 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_type(self):
params = {'type': [DataSourceTypeChoices.LOCAL]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -97,6 +107,10 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DataFile.objects.bulk_create(data_files)
def test_q(self):
params = {'q': 'file1.txt'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_source(self):
sources = DataSource.objects.all()
params = {'source_id': [sources[0].pk, sources[1].pk]}

View File

@@ -1,4 +1,5 @@
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, redirect
from extras.models import ConfigRevision
@@ -153,9 +154,11 @@ class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
if config := self.queryset.first():
return config
# Instantiate a dummy default config if none has been created yet
return ConfigRevision(
data=get_config().defaults
)
revision_id = cache.get('config_version')
try:
return ConfigRevision.objects.get(pk=revision_id)
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
data=get_config()
)

View File

@@ -325,7 +325,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
]
def search(self, queryset, name, value):
@@ -336,6 +336,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
Q(facility_id__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -497,7 +498,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
fields = [
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit', 'description',
]
def search(self, queryset, name, value):
@@ -507,6 +509,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
Q(part_number__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -591,7 +594,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -600,6 +603,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
Q(part_number__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -639,7 +643,10 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
@@ -654,21 +661,21 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
class Meta:
model = ConsolePortTemplate
fields = ['id', 'name', 'type']
fields = ['id', 'name', 'type', 'description']
class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'name', 'type']
fields = ['id', 'name', 'type', 'description']
class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -679,7 +686,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
class Meta:
model = PowerOutletTemplate
fields = ['id', 'name', 'type', 'feed_leg']
fields = ['id', 'name', 'type', 'feed_leg', 'description']
class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -703,7 +710,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = InterfaceTemplate
fields = ['id', 'name', 'type', 'enabled', 'mgmt_only']
fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description']
class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -714,7 +721,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = FrontPortTemplate
fields = ['id', 'name', 'type', 'color']
fields = ['id', 'name', 'type', 'color', 'description']
class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -725,21 +732,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
class Meta:
model = RearPortTemplate
fields = ['id', 'name', 'type', 'color', 'positions']
fields = ['id', 'name', 'type', 'color', 'positions', 'description']
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = ModuleBayTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'description']
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'description']
class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -772,7 +779,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
class Meta:
model = InventoryItemTemplate
fields = ['id', 'name', 'label', 'part_id']
fields = ['id', 'name', 'label', 'part_id', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -1008,7 +1015,10 @@ class DeviceFilterSet(
class Meta:
model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
fields = [
'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority',
'description',
]
def search(self, queryset, name, value):
if not value.strip():
@@ -1018,6 +1028,7 @@ class DeviceFilterSet(
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value.strip()) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
@@ -1087,13 +1098,16 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim
class Meta:
model = VirtualDeviceContext
fields = ['id', 'device', 'name']
fields = ['id', 'device', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value)
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value)
)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
@@ -1150,7 +1164,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
class Meta:
model = Module
fields = ['id', 'status', 'asset_tag']
fields = ['id', 'status', 'asset_tag', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -1159,6 +1173,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value) |
Q(comments__icontains=value)
).distinct()
@@ -1649,7 +1664,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = InventoryItemRole
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'description']
class VirtualChassisFilterSet(NetBoxModelFilterSet):
@@ -1714,13 +1729,14 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class Meta:
model = VirtualChassis
fields = ['id', 'domain', 'name']
fields = ['id', 'domain', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(members__name__icontains=value) |
Q(domain__icontains=value)
)
@@ -1789,12 +1805,16 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
qs_filter = (
Q(label__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
def filter_by_termination(self, queryset, name, value):
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
@@ -1881,13 +1901,14 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta:
model = PowerPanel
fields = ['id', 'name']
fields = ['id', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
@@ -1948,6 +1969,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
model = PowerFeed
fields = [
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
'description',
]
def search(self, queryset, name, value):
@@ -1955,6 +1977,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(power_panel__name__icontains=value) |
Q(comments__icontains=value)
)

View File

@@ -412,7 +412,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
)
u_height = forms.IntegerField(
label=_('U height'),
min_value=1,
min_value=0,
required=False
)
is_full_depth = forms.NullBooleanField(

View File

@@ -0,0 +1,22 @@
from django.db import migrations
def update_cable_lengths(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
# Set the absolute length for any zero-length Cables
Cable.objects.filter(length=0).update(_abs_length=0)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0181_rename_device_role_device_role'),
]
operations = [
migrations.RunPython(
code=update_cable_lengths,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -201,7 +201,7 @@ class Cable(PrimaryModel):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
if self.length is not None and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None

View File

@@ -274,7 +274,7 @@ class CableTraceSVG:
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
@@ -285,7 +285,7 @@ class CableTraceSVG:
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')

View File

@@ -1078,7 +1078,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:vdc_list'
url_name='dcim:virtualdevicecontext_list'
)
class Meta(NetBoxTable.Meta):

File diff suppressed because it is too large Load Diff

View File

@@ -695,8 +695,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
label=_('Reservations'),
badge=lambda obj: obj.reservations.count(),
permission='dcim.view_rackreservation',
weight=510,
hide_if_empty=True
weight=510
)
def get_children(self, request, parent):

View File

@@ -512,7 +512,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ConfigContext
fields = ['id', 'name', 'is_active', 'data_synced']
fields = ['id', 'name', 'is_active', 'data_synced', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -114,7 +114,7 @@ class Command(BaseCommand):
# Create the job
job = Job.objects.create(
object=module,
name=script.name,
name=script.class_name,
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
job_id=uuid.uuid4()
)

View File

@@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@@ -571,8 +570,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Multiselect
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
filter_class = filters.MultiValueCharFilter
kwargs['lookup_expr'] = 'has_key'
filter_class = filters.MultiValueArrayFilter
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

@@ -315,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)

View File

@@ -62,21 +62,20 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Record an ObjectChange
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
queue = webhooks_queue.get()

View File

@@ -0,0 +1,265 @@
from django.test import TestCase
from django.test import override_settings
from circuits.api.serializers import ProviderSerializer
from circuits.forms import ProviderForm
from circuits.models import Provider
from ipam.models import ASN, RIR
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
class ModelFormCustomValidationTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
class BulkEditCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
for provider in providers:
provider.asns.set(asns)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_without_m2m(self):
"""
Check that custom validation rules do not interfere with bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
)
# Bulk edit the description without changing ASN assignments
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(
Provider.objects.filter(description=data['description']).count(),
len(data['pk'])
)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_m2m(self):
"""
Test that custom validation rules are enforced during bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
'ipam.view_asn',
)
# Change the ASN assignments
asn = ASN.objects.first()
data['asns'] = [asn.pk]
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
for provider in Provider.objects.all():
self.assertEqual(len(provider.asns.all()), 1)
# Attempt to remove the ASN assignments
data.pop('asns')
data['_nullify'] = 'asns'
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
for provider in Provider.objects.all():
self.assertTrue(provider.asns.exists())
class BulkImportCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
create_tags('Tag1', 'Tag2', 'Tag3')
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_bulk_import_invalid(self):
"""
Test that custom validation rules are enforced during bulk import.
"""
csv_data = (
"name,slug",
"Provider 1,provider-1",
"Provider 2,provider-2",
"Provider 3,provider-3",
)
data = {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.COMMA,
}
self.add_permissions(
'circuits.view_provider',
'circuits.add_provider',
'extras.view_tag',
)
# Attempt to import providers without tags
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertFalse(Provider.objects.exists())
# Import providers successfully with tag assignments
csv_data = (
"name,slug,tags",
"Provider 1,provider-1,tag1",
"Provider 2,provider-2,tag2",
"Provider 3,provider-3,tag3",
)
data['data'] = '\n'.join(csv_data)
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertTrue(Provider.objects.exists())
class APISerializerCustomValidationTest(APITestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())

View File

@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
)
# Integer filtering
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
'cf9': 'A',
'cf10': ['A', 'X'],
'cf10': ['A', 'B'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}),
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
'cf9': 'B',
'cf10': ['B', 'X'],
'cf10': ['B', 'C'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}),
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
'cf9': 'C',
'cf10': ['C', 'X'],
'cf10': None,
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}),
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)

View File

@@ -40,7 +40,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
required=True,
weight=100,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
description='foobar1'
),
CustomField(
name='Custom Field 2',
@@ -48,7 +49,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
required=False,
weight=200,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY,
description='foobar2'
),
CustomField(
name='Custom Field 3',
@@ -56,7 +58,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
required=False,
weight=300,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
description='foobar3'
),
CustomField(
name='Custom Field 4',
@@ -84,6 +87,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -116,6 +123,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
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 CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()
@@ -124,12 +135,16 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C'], description='foobar1'),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F'], description='foobar2'),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I'], description='foobar3'),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -138,6 +153,10 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
params = {'choice': ['A', 'D']}
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 WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()
@@ -216,6 +235,10 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
webhooks[3].content_types.add(content_types[3])
webhooks[4].content_types.add(content_types[4])
def test_q(self):
params = {'q': 'Webhook 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Webhook 1', 'Webhook 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -297,6 +320,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'Custom Link 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Custom Link 1', 'Custom Link 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -347,7 +374,8 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=100,
enabled=True,
shared=True,
parameters={'status': ['active']}
parameters={'status': ['active']},
description='foobar1'
),
SavedFilter(
name='Saved Filter 2',
@@ -356,7 +384,8 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=200,
enabled=True,
shared=True,
parameters={'status': ['planned']}
parameters={'status': ['planned']},
description='foobar2'
),
SavedFilter(
name='Saved Filter 3',
@@ -365,13 +394,18 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=300,
enabled=False,
shared=False,
parameters={'status': ['retired']}
parameters={'status': ['retired']},
description='foobar3'
),
)
SavedFilter.objects.bulk_create(saved_filters)
for i, savedfilter in enumerate(saved_filters):
savedfilter.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Saved Filter 1', 'Saved Filter 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -380,6 +414,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
params = {'slug': ['saved-filter-1', 'saved-filter-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_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -423,8 +461,6 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
users = (
User(username='User 1'),
User(username='User 2'),
@@ -505,6 +541,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
for i, et in enumerate(export_templates):
et.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -578,6 +618,10 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
)
ImageAttachment.objects.bulk_create(image_attachments)
def test_q(self):
params = {'q': 'Attachment 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -630,41 +674,45 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
assigned_object=sites[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
comments='foobar1'
),
JournalEntry(
assigned_object=sites[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
comments='foobar2'
),
JournalEntry(
assigned_object=sites[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
comments='foobar3'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
comments='foobar4'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
comments='foobar5'
),
JournalEntry(
assigned_object=racks[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
comments='foobar6'
),
)
JournalEntry.objects.bulk_create(journal_entries)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_created_by(self):
users = User.objects.filter(username__in=['Alice', 'Bob'])
params = {'created_by': [users[0].username, users[1].username]}
@@ -800,9 +848,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
for i in range(0, 3):
is_active = bool(i % 2)
c = ConfigContext.objects.create(
name='Config Context {}'.format(i + 1),
name=f"Config Context {i + 1}",
is_active=is_active,
data='{"foo": 123}'
data='{"foo": 123}',
description=f"foobar{i + 1}"
)
c.regions.set([regions[i]])
c.site_groups.set([site_groups[i]])
@@ -818,6 +867,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
c.tenants.set([tenants[i]])
c.tags.set([tags[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Config Context 1', 'Config Context 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -828,6 +881,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'is_active': False}
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_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -929,6 +986,10 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
)
ConfigTemplate.objects.bulk_create(config_templates)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Config Template 1', 'Config Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -965,6 +1026,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
site.tags.set([tags[0]])
provider.tags.set([tags[1]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Tag 1', 'Tag 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1076,6 +1141,10 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
)
ObjectChange.objects.bulk_create(object_changes)
def test_q(self):
params = {'q': 'Site 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@@ -1,5 +1,6 @@
from django.core.exceptions import ValidationError
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
# NOTE: As this module may be imported by configuration.py, we cannot import
# anything from NetBox itself.
@@ -66,8 +67,7 @@ class CustomValidator:
def __call__(self, instance):
# Validate instance attributes per validation rules
for attr_name, rules in self.validation_rules.items():
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
attr = getattr(instance, attr_name)
attr = self._getattr(instance, attr_name)
for descriptor, value in rules.items():
validator = self.get_validator(descriptor, value)
try:
@@ -79,6 +79,26 @@ class CustomValidator:
# Execute custom validation logic (if any)
self.validate(instance)
@staticmethod
def _getattr(instance, name):
# Attempt to resolve many-to-many fields to their stored values
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
if name in m2m_fields:
if name in getattr(instance, '_m2m_values', []):
return instance._m2m_values[name]
if instance.pk:
return list(getattr(instance, name).all())
return []
# Raise a ValidationError for unknown attributes
if not hasattr(instance, name):
raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
name=name,
model=instance.__class__.__name__
))
return getattr(instance, name)
def get_validator(self, descriptor, value):
"""
Instantiate and return the appropriate validator based on the descriptor given. For

View File

@@ -759,7 +759,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
class Meta:
model = FHRPGroup
fields = ['id', 'group_id', 'name', 'auth_key']
fields = ['id', 'group_id', 'name', 'auth_key', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -950,6 +950,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
choices=VLANStatusChoices,
null_value=None
)
available_at_site = django_filters.ModelChoiceFilter(
queryset=Site.objects.all(),
method='get_for_site'
)
available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
method='get_for_device'
@@ -984,6 +988,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass
return queryset.filter(qs_filter)
@extend_schema_field(OpenApiTypes.STR)
def get_for_site(self, queryset, name, value):
return queryset.get_for_site(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value)
@@ -1001,12 +1009,15 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class Meta:
model = ServiceTemplate
fields = ['id', 'name', 'protocol']
fields = ['id', 'name', 'protocol', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)

View File

@@ -864,11 +864,9 @@ class IPAddress(PrimaryModel):
is_primary = True
if is_primary and (parent != original_parent):
raise ValidationError({
'assigned_object': _(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
})
raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:

View File

@@ -224,11 +224,11 @@ class VLAN(PrimaryModel):
# Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site:
raise ValidationError({
'group': _(
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
})
)
# Validate group min/max VIDs
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:

View File

@@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet):
class VLANQuerySet(RestrictedQuerySet):
def get_for_site(self, site):
"""
Return all VLANs in the specified site
"""
from .models import VLANGroup
q = Q()
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
scope_id=site.pk
)
if site.region:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
scope_id__in=site.region.get_ancestors(include_self=True)
)
if site.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=site.group.get_ancestors(include_self=True)
)
return self.filter(
Q(group__in=VLANGroup.objects.filter(q)) |
Q(site=site) |
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
def get_for_device(self, device):
"""
Return all VLANs available to the specified Device.

View File

@@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs):
"""
field_name = f'primary_ip{instance.family}'
if device := Device.objects.filter(**{field_name: instance}).first():
device.snapshot()
setattr(device, field_name, None)
device.save()
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
virtualmachine.snapshot()
setattr(virtualmachine, field_name, None)
virtualmachine.save()
@@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs):
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
"""
if device := Device.objects.filter(oob_ip=instance).first():
device.snapshot()
device.oob_ip = None
device.save()

View File

@@ -39,7 +39,7 @@ class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=None,
start=65000,
end=65009,
description='aaa'
description='foobar1'
),
ASNRange(
name='ASN Range 2',
@@ -48,7 +48,7 @@ class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[0],
start=65010,
end=65019,
description='bbb'
description='foobar2'
),
ASNRange(
name='ASN Range 3',
@@ -57,11 +57,15 @@ class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[1],
start=65020,
end=65029,
description='ccc'
description='foobar3'
),
)
ASNRange.objects.bulk_create(asn_ranges)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['ASN Range 1', 'ASN Range 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -89,7 +93,7 @@ class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['aaa', 'bbb']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -123,9 +127,9 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
asns = (
ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='aaa'),
ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='bbb'),
ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='ccc'),
ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='foobar1'),
ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='foobar2'),
ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='foobar3'),
ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]),
ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]),
ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]),
@@ -139,6 +143,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
asns[4].sites.set([sites[1]])
asns[5].sites.set([sites[2]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_asn(self):
params = {'asn': [65001, 4200000000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -165,7 +173,7 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['aaa', 'bbb']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -214,6 +222,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
vrfs[2].import_targets.add(route_targets[2])
vrfs[2].export_targets.add(route_targets[2])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['VRF 1', 'VRF 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -310,6 +322,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
vrfs[1].import_targets.add(route_targets[4], route_targets[5])
vrfs[1].export_targets.add(route_targets[6], route_targets[7])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['65000:1001', '65000:1002', '65000:1003']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -355,15 +371,19 @@ class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
rirs = (
RIR(name='RIR 1', slug='rir-1', is_private=False, description='A'),
RIR(name='RIR 2', slug='rir-2', is_private=False, description='B'),
RIR(name='RIR 3', slug='rir-3', is_private=False, description='C'),
RIR(name='RIR 4', slug='rir-4', is_private=True, description='D'),
RIR(name='RIR 5', slug='rir-5', is_private=True, description='E'),
RIR(name='RIR 6', slug='rir-6', is_private=True, description='F'),
RIR(name='RIR 1', slug='rir-1', is_private=False, description='foobar1'),
RIR(name='RIR 2', slug='rir-2', is_private=False, description='foobar2'),
RIR(name='RIR 3', slug='rir-3', is_private=False, description='foobar3'),
RIR(name='RIR 4', slug='rir-4', is_private=True),
RIR(name='RIR 5', slug='rir-5', is_private=True),
RIR(name='RIR 6', slug='rir-6', is_private=True),
)
RIR.objects.bulk_create(rirs)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['RIR 1', 'RIR 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -373,7 +393,7 @@ class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_private(self):
@@ -422,6 +442,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Aggregate.objects.bulk_create(aggregates)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_family(self):
params = {'family': '4'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -475,6 +499,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Role.objects.bulk_create(roles)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Role 1', 'Role 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -579,6 +607,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
for prefix in prefixes:
prefix.save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_family(self):
params = {'family': '6'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
@@ -745,17 +777,87 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
ip_ranges = (
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
IPRange(start_address='2001:db8:0:2::1/64', end_address='2001:db8:0:2::100/64', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE),
IPRange(start_address='2001:db8:0:3::1/64', end_address='2001:db8:0:3::100/64', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
IPRange(start_address='2001:db8:0:4::1/64', end_address='2001:db8:0:4::100/64', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
IPRange(
start_address='10.0.1.100/24',
end_address='10.0.1.199/24',
size=100,
vrf=None,
tenant=None,
role=None,
status=IPRangeStatusChoices.STATUS_ACTIVE,
description='foobar1'
),
IPRange(
start_address='10.0.2.100/24',
end_address='10.0.2.199/24',
size=100,
vrf=vrfs[0],
tenant=tenants[0],
role=roles[0],
status=IPRangeStatusChoices.STATUS_ACTIVE,
description='foobar2'
),
IPRange(
start_address='10.0.3.100/24',
end_address='10.0.3.199/24',
size=100,
vrf=vrfs[1],
tenant=tenants[1],
role=roles[1],
status=IPRangeStatusChoices.STATUS_DEPRECATED
),
IPRange(
start_address='10.0.4.100/24',
end_address='10.0.4.199/24',
size=100,
vrf=vrfs[2],
tenant=tenants[2],
role=roles[2],
status=IPRangeStatusChoices.STATUS_RESERVED
),
IPRange(
start_address='2001:db8:0:1::1/64',
end_address='2001:db8:0:1::100/64',
size=100,
vrf=None,
tenant=None,
role=None,
status=IPRangeStatusChoices.STATUS_ACTIVE
),
IPRange(
start_address='2001:db8:0:2::1/64',
end_address='2001:db8:0:2::100/64',
size=100,
vrf=vrfs[0],
tenant=tenants[0],
role=roles[0],
status=IPRangeStatusChoices.STATUS_ACTIVE
),
IPRange(
start_address='2001:db8:0:3::1/64',
end_address='2001:db8:0:3::100/64',
size=100,
vrf=vrfs[1],
tenant=tenants[1],
role=roles[1],
status=IPRangeStatusChoices.STATUS_DEPRECATED
),
IPRange(
start_address='2001:db8:0:4::1/64',
end_address='2001:db8:0:4::100/64',
size=100,
vrf=vrfs[2],
tenant=tenants[2],
role=roles[2],
status=IPRangeStatusChoices.STATUS_RESERVED
),
)
IPRange.objects.bulk_create(ip_ranges)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_family(self):
params = {'family': '6'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@@ -889,21 +991,111 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
ipaddresses = (
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar1'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.5/24', tenant=None, vrf=None, assigned_object=fhrp_groups[0], status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar2'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::5/64', tenant=None, vrf=None, assigned_object=fhrp_groups[1], status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(
address='10.0.0.1/24',
tenant=None,
vrf=None,
assigned_object=None,
status=IPAddressStatusChoices.STATUS_ACTIVE,
dns_name='ipaddress-a',
description='foobar1'
),
IPAddress(
address='10.0.0.2/24',
tenant=tenants[0],
vrf=vrfs[0],
assigned_object=interfaces[0],
status=IPAddressStatusChoices.STATUS_ACTIVE,
dns_name='ipaddress-b'
),
IPAddress(
address='10.0.0.3/24',
tenant=tenants[1],
vrf=vrfs[1],
assigned_object=interfaces[1],
status=IPAddressStatusChoices.STATUS_RESERVED,
role=IPAddressRoleChoices.ROLE_VIP,
dns_name='ipaddress-c'
),
IPAddress(
address='10.0.0.4/24',
tenant=tenants[2],
vrf=vrfs[2],
assigned_object=interfaces[2],
status=IPAddressStatusChoices.STATUS_DEPRECATED,
role=IPAddressRoleChoices.ROLE_SECONDARY,
dns_name='ipaddress-d'
),
IPAddress(
address='10.0.0.5/24',
tenant=None,
vrf=None,
assigned_object=fhrp_groups[0],
status=IPAddressStatusChoices.STATUS_ACTIVE
),
IPAddress(
address='10.0.0.1/25',
tenant=None,
vrf=None,
assigned_object=None,
status=IPAddressStatusChoices.STATUS_ACTIVE
),
IPAddress(
address='2001:db8::1/64',
tenant=None,
vrf=None,
assigned_object=None,
status=IPAddressStatusChoices.STATUS_ACTIVE,
dns_name='ipaddress-a',
description='foobar2'
),
IPAddress(
address='2001:db8::2/64',
tenant=tenants[0],
vrf=vrfs[0],
assigned_object=vminterfaces[0],
status=IPAddressStatusChoices.STATUS_ACTIVE,
dns_name='ipaddress-b'
),
IPAddress(
address='2001:db8::3/64',
tenant=tenants[1],
vrf=vrfs[1],
assigned_object=vminterfaces[1],
status=IPAddressStatusChoices.STATUS_RESERVED,
role=IPAddressRoleChoices.ROLE_VIP,
dns_name='ipaddress-c'
),
IPAddress(
address='2001:db8::4/64',
tenant=tenants[2],
vrf=vrfs[2],
assigned_object=vminterfaces[2],
status=IPAddressStatusChoices.STATUS_DEPRECATED,
role=IPAddressRoleChoices.ROLE_SECONDARY,
dns_name='ipaddress-d'
),
IPAddress(
address='2001:db8::5/64',
tenant=None,
vrf=None,
assigned_object=fhrp_groups[1],
status=IPAddressStatusChoices.STATUS_ACTIVE
),
IPAddress(
address='2001:db8::1/65',
tenant=None,
vrf=None,
assigned_object=None,
status=IPAddressStatusChoices.STATUS_ACTIVE
),
)
IPAddress.objects.bulk_create(ipaddresses)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_family(self):
params = {'family': '4'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
@@ -1055,15 +1247,36 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
IPAddress.objects.bulk_create(ip_addresses)
fhrp_groups = (
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foo123'),
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456', name='bar123'),
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30),
FHRPGroup(
protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2,
group_id=10,
auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT,
auth_key='foo123',
description='foobar1'
),
FHRPGroup(
protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3,
group_id=20,
auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5,
auth_key='bar456',
name='bar123',
description='foobar2'
),
FHRPGroup(
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
group_id=30,
description='foobar3'
),
)
FHRPGroup.objects.bulk_create(fhrp_groups)
fhrp_groups[0].ip_addresses.set([ip_addresses[0]])
fhrp_groups[1].ip_addresses.set([ip_addresses[1]])
fhrp_groups[2].ip_addresses.set([ip_addresses[2]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_protocol(self):
params = {'protocol': [FHRPGroupProtocolChoices.PROTOCOL_VRRP2, FHRPGroupProtocolChoices.PROTOCOL_VRRP3]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1084,6 +1297,10 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['bar123', ]}
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)
def test_related_ip(self):
# Create some regular IPs to query for related IPs
ipaddresses = (
@@ -1199,17 +1416,21 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
cluster.save()
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='A'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='B'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='C'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location, description='D'),
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack, description='E'),
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup, description='F'),
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster, description='G'),
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='foobar1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='foobar2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='foobar3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location),
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack),
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup),
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster),
VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
)
VLANGroup.objects.bulk_create(vlan_groups)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1219,7 +1440,7 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
@@ -1359,6 +1580,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
)
VLANGroup.objects.bulk_create(groups)
@@ -1415,11 +1637,18 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
# Create one globally available VLAN on a VLAN group
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
# Create one globally available VLAN
VLAN(vid=1000, name='Global VLAN'),
)
VLAN.objects.bulk_create(vlans)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['VLAN 101', 'VLAN 102']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1488,12 +1717,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_available_on_device(self):
device_id = Device.objects.first().pk
params = {'available_on_device': device_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
def test_available_on_virtualmachine(self):
vm_id = VirtualMachine.objects.first().pk
params = {'available_on_virtualmachine': vm_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
def test_available_at_site(self):
site_id = Site.objects.first().pk
params = {'available_at_site': site_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global
class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -1503,15 +1737,46 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
service_templates = (
ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
ServiceTemplate(name='Service Template 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
ServiceTemplate(name='Service Template 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
ServiceTemplate(name='Service Template 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
ServiceTemplate(
name='Service Template 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1001],
description='foobar1'
),
ServiceTemplate(
name='Service Template 2',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1002],
description='foobar2'
),
ServiceTemplate(
name='Service Template 3',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[1003],
description='foobar3'
),
ServiceTemplate(
name='Service Template 4',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2001]
),
ServiceTemplate(
name='Service Template 5',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2002]
),
ServiceTemplate(
name='Service Template 6',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[2003]
),
)
ServiceTemplate.objects.bulk_create(service_templates)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Service Template 1', 'Service Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1524,6 +1789,10 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'port': '1001'}
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 ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Service.objects.all()
@@ -1580,6 +1849,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
services[1].ipaddresses.add(ip_addresses[1])
services[2].ipaddresses.add(ip_addresses[2])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Service 1', 'Service 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1636,9 +1909,26 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
RouteTarget.objects.bulk_create(route_targets)
l2vpns = (
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
L2VPN(
name='L2VPN 1',
slug='l2vpn-1',
type=L2VPNTypeChoices.TYPE_VXLAN,
identifier=65001,
description='foobar1'
),
L2VPN(
name='L2VPN 2',
slug='l2vpn-2',
type=L2VPNTypeChoices.TYPE_VPWS,
identifier=65002,
description='foobar2'
),
L2VPN(
name='L2VPN 3',
slug='l2vpn-3',
type=L2VPNTypeChoices.TYPE_VPLS,
description='foobar3'
),
)
L2VPN.objects.bulk_create(l2vpns)
l2vpns[0].import_targets.add(route_targets[0])
@@ -1648,6 +1938,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
l2vpns[1].export_targets.add(route_targets[4])
l2vpns[2].export_targets.add(route_targets[5])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['L2VPN 1', 'L2VPN 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1664,6 +1958,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
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_import_targets(self):
route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2'])
params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}

View File

@@ -661,6 +661,26 @@ class IPRangeListView(generic.ObjectListView):
class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all()
def get_extra_context(self, request, instance):
# Parent prefixes table
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(prefix__net_contains_or_equals=str(instance.start_address.ip)),
Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
vrf=instance.vrf
).prefetch_related(
'site', 'role', 'tenant', 'vlan', 'role'
)
parent_prefixes_table = tables.PrefixTable(
list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
return {
'parent_prefixes_table': parent_prefixes_table,
}
@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
class IPRangeIPAddressesView(generic.ObjectChildrenView):

View File

@@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer):
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
def validate(self, data):
# Remove custom fields data and tags (if any) prior to model validation
attrs = data.copy()
# Remove custom field data (if any) prior to model validation
attrs.pop('custom_fields', None)
attrs.pop('tags', None)
# Skip ManyToManyFields
for field in self.Meta.model._meta.get_fields():
if isinstance(field, ManyToManyField):
attrs.pop(field.name, None)
m2m_values = {}
for field in self.Meta.model._meta.local_many_to_many:
if field.name in attrs:
m2m_values[field.name] = attrs.pop(field.name)
# Run clean() on an instance of the model
if self.instance is None:
@@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
instance = self.instance
for k, v in attrs.items():
setattr(instance, k, v)
instance._m2m_values = m2m_values
instance.full_clean()
return data

View File

@@ -315,5 +315,6 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet):
return queryset
return queryset.filter(
models.Q(name__icontains=value) |
models.Q(slug__icontains=value)
models.Q(slug__icontains=value) |
models.Q(description__icontains=value)
)

View File

@@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
return super().clean()
def _post_clean(self):
"""
Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
"""
self.instance._m2m_values = {}
for field in self.instance._meta.local_many_to_many:
if field.name in self.cleaned_data:
self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
return super()._post_clean()
class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
"""

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.7'
VERSION = '3.6.9'
# Hostname
HOSTNAME = platform.node()

View File

@@ -557,6 +557,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
elif name in form.changed_data:
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
# Store M2M values for validation
obj._m2m_values = {}
for field in obj._meta.local_many_to_many:
if value := form.cleaned_data.get(field.name):
obj._m2m_values[field.name] = list(value)
elif field.name in nullified_fields:
obj._m2m_values[field.name] = []
obj.full_clean()
obj.save()
updated_objects.append(obj)

View File

@@ -50,7 +50,7 @@
<tr>
<th scope="row">{% trans "Length" %}</th>
<td>
{% if object.length %}
{% if object.length is not None %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
{{ ''|placeholder }}

View File

@@ -82,6 +82,11 @@
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}

View File

@@ -65,7 +65,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
class Meta:
model = Contact
fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link']
fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -77,6 +77,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
Q(email__icontains=value) |
Q(address__icontains=value) |
Q(link__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)

View File

@@ -23,13 +23,32 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
tenantgroup.save()
tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0], description='A'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1], description='B'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2], description='C'),
TenantGroup(
name='Tenant Group 1',
slug='tenant-group-1',
parent=parent_tenant_groups[0],
description='foobar1'
),
TenantGroup(
name='Tenant Group 2',
slug='tenant-group-2',
parent=parent_tenant_groups[1],
description='foobar2'
),
TenantGroup(
name='Tenant Group 3',
slug='tenant-group-3',
parent=parent_tenant_groups[2],
description='foobar3'
),
)
for tenantgroup in tenant_groups:
tenantgroup.save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Tenant Group 1', 'Tenant Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -39,7 +58,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
@@ -68,10 +87,14 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0], description='foobar1'),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1], description='foobar2'),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2], description='foobar3'),
)
Tenant.objects.bulk_create(tenants)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Tenant 1', 'Tenant 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -108,13 +131,32 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
contactgroup.save()
contact_groups = (
ContactGroup(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0], description='A'),
ContactGroup(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[1], description='B'),
ContactGroup(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[2], description='C'),
ContactGroup(
name='Contact Group 1',
slug='contact-group-1',
parent=parent_contact_groups[0],
description='foobar1'
),
ContactGroup(
name='Contact Group 2',
slug='contact-group-2',
parent=parent_contact_groups[1],
description='foobar2'
),
ContactGroup(
name='Contact Group 3',
slug='contact-group-3',
parent=parent_contact_groups[2],
description='foobar3'
),
)
for contactgroup in contact_groups:
contactgroup.save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Contact Group 1', 'Contact Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -124,7 +166,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
@@ -145,10 +187,14 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
contact_roles = (
ContactRole(name='Contact Role 1', slug='contact-role-1', description='foobar1'),
ContactRole(name='Contact Role 2', slug='contact-role-2', description='foobar2'),
ContactRole(name='Contact Role 3', slug='contact-role-3'),
ContactRole(name='Contact Role 3', slug='contact-role-3', description='foobar3'),
)
ContactRole.objects.bulk_create(contact_roles)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Contact Role 1', 'Contact Role 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -178,16 +224,24 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
contactgroup.save()
contacts = (
Contact(name='Contact 1', group=contact_groups[0]),
Contact(name='Contact 2', group=contact_groups[1]),
Contact(name='Contact 3', group=contact_groups[2]),
Contact(name='Contact 1', group=contact_groups[0], description='foobar1'),
Contact(name='Contact 2', group=contact_groups[1], description='foobar2'),
Contact(name='Contact 3', group=contact_groups[2], description='foobar3'),
)
Contact.objects.bulk_create(contacts)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Contact 1', 'Contact 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_group(self):
group = ContactGroup.objects.all()[:2]
params = {'group_id': [group[0].pk, group[1].pk]}

View File

@@ -67,6 +67,10 @@ class UserTestCase(TestCase, BaseFilterSetTests):
users[1].groups.set([groups[1]])
users[2].groups.set([groups[2]])
def test_q(self):
params = {'q': 'user1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_username(self):
params = {'username': ['User1', 'User2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -117,6 +121,10 @@ class GroupTestCase(TestCase, BaseFilterSetTests):
)
Group.objects.bulk_create(groups)
def test_q(self):
params = {'q': 'group 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Group 1', 'Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -164,6 +172,10 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
permissions[i].users.set([users[i]])
permissions[i].object_types.set([object_types[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Permission 1', 'Permission 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -235,6 +247,10 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
)
Token.objects.bulk_create(tokens)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_user(self):
users = User.objects.order_by('id')[:2]
params = {'user_id': [users[0].pk, users[1].pk]}

View File

@@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes
__all__ = (
'ContentTypeFilter',
'MACAddressFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
'MultiValueDateFilter',
'MultiValueDateTimeFilter',
@@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.TimeField)
@extend_schema_field(OpenApiTypes.STR)
class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.CharField)
def __init__(self, *args, lookup_expr='contains', **kwargs):
# Set default lookup_expr to 'contains'
super().__init__(*args, lookup_expr=lookup_expr, **kwargs)
def get_filter_predicate(self, v):
# If filtering for null values, ignore lookup_expr
if v is None:
return {self.field_name: None}
return super().get_filter_predicate(v)
class MACAddressFilter(django_filters.CharFilter):
pass

View File

@@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField):
if data is not None:
self.choices = [
choice for choice in self.choices if choice[0] in data
choice for choice in self.choices if choice[0] and choice[0] in data
]
return bound_field

View File

@@ -100,13 +100,14 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class Meta:
model = Cluster
fields = ['id', 'name']
fields = ['id', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -238,13 +239,14 @@ class VirtualMachineFilterSet(
class Meta:
model = VirtualMachine
fields = ['id', 'cluster', 'vcpus', 'memory', 'disk']
fields = ['id', 'cluster', 'vcpus', 'memory', 'disk', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)

View File

@@ -17,12 +17,16 @@ class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
cluster_types = (
ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='A'),
ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='B'),
ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='C'),
ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='foobar1'),
ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='foobar2'),
ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='foobar3'),
)
ClusterType.objects.bulk_create(cluster_types)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Cluster Type 1', 'Cluster Type 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -32,7 +36,7 @@ class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -44,12 +48,16 @@ class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='A'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='B'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='C'),
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='foobar1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='foobar2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='foobar3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Cluster Group 1', 'Cluster Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -59,7 +67,7 @@ class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -123,16 +131,48 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
clusters = (
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
Cluster(
name='Cluster 1',
type=cluster_types[0],
group=cluster_groups[0],
status=ClusterStatusChoices.STATUS_PLANNED,
site=sites[0],
tenant=tenants[0],
description='foobar1'
),
Cluster(
name='Cluster 2',
type=cluster_types[1],
group=cluster_groups[1],
status=ClusterStatusChoices.STATUS_STAGING,
site=sites[1],
tenant=tenants[1],
description='foobar2'
),
Cluster(
name='Cluster 3',
type=cluster_types[2],
group=cluster_groups[2],
status=ClusterStatusChoices.STATUS_ACTIVE,
site=sites[2],
tenant=tenants[2],
description='foobar3'
),
)
Cluster.objects.bulk_create(clusters)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Cluster 1', 'Cluster 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_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -274,9 +314,49 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
VirtualMachine(
name='Virtual Machine 1',
site=sites[0],
cluster=clusters[0],
device=devices[0],
platform=platforms[0],
role=roles[0],
tenant=tenants[0],
status=VirtualMachineStatusChoices.STATUS_ACTIVE,
vcpus=1,
memory=1,
disk=1,
description='foobar1',
local_context_data={"foo": 123}
),
VirtualMachine(
name='Virtual Machine 2',
site=sites[1],
cluster=clusters[1],
device=devices[1],
platform=platforms[1],
role=roles[1],
tenant=tenants[1],
status=VirtualMachineStatusChoices.STATUS_STAGED,
vcpus=2,
memory=2,
disk=2,
description='foobar2'
),
VirtualMachine(
name='Virtual Machine 3',
site=sites[2],
cluster=clusters[2],
device=devices[2],
platform=platforms[2],
role=roles[2],
tenant=tenants[2],
status=VirtualMachineStatusChoices.STATUS_OFFLINE,
vcpus=3,
memory=3,
disk=3,
description='foobar3'
),
)
VirtualMachine.objects.bulk_create(vms)
@@ -300,6 +380,10 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -307,6 +391,10 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['VIRTUAL MACHINE 1', 'VIRTUAL MACHINE 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_vcpus(self):
params = {'vcpus': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -467,12 +555,40 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(vms)
interfaces = (
VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0], description='foobar1'),
VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1], description='foobar2'),
VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03', vrf=vrfs[2]),
VMInterface(
virtual_machine=vms[0],
name='Interface 1',
enabled=True,
mtu=100,
mac_address='00-00-00-00-00-01',
vrf=vrfs[0],
description='foobar1'
),
VMInterface(
virtual_machine=vms[1],
name='Interface 2',
enabled=True,
mtu=200,
mac_address='00-00-00-00-00-02',
vrf=vrfs[1],
description='foobar2'
),
VMInterface(
virtual_machine=vms[2],
name='Interface 3',
enabled=False,
mtu=300,
mac_address='00-00-00-00-00-03',
vrf=vrfs[2],
description='foobar3'
),
)
VMInterface.objects.bulk_create(interfaces)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -36,6 +36,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
for group in child_groups:
group.save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -103,7 +107,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[0],
auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1'
auth_psk='PSK1',
description='foobar1'
),
WirelessLAN(
ssid='WLAN2',
@@ -113,7 +118,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[1],
auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2'
auth_psk='PSK2',
description='foobar2'
),
WirelessLAN(
ssid='WLAN3',
@@ -123,11 +129,16 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[2],
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3'
auth_psk='PSK3',
description='foobar3'
),
)
WirelessLAN.objects.bulk_create(wireless_lans)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_ssid(self):
params = {'ssid': ['WLAN1', 'WLAN2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -160,6 +171,10 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -240,6 +255,10 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
ssid='LINK4'
).save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_ssid(self):
params = {'ssid': ['LINK1', 'LINK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -9,7 +9,7 @@ django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.4.0
django-rich==1.8.0
django-rq==2.9.0
django-rq==2.10.1
django-tables2==2.7.0
django-taggit==4.0.0
django-timezone-field==6.1.0
@@ -21,11 +21,11 @@ graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.5.2
mkdocs-material==9.5.3
mkdocstrings[python-legacy]==0.24.0
netaddr==0.9.0
Pillow==10.1.0
psycopg[binary,pool]==3.1.15
psycopg[binary,pool]==3.1.16
PyYAML==6.0.1
requests==2.31.0
sentry-sdk==1.39.1

View File

@@ -7,6 +7,10 @@
# Python 3.8 or later.
cd "$(dirname "$0")"
NETBOX_VERSION="$(grep ^VERSION netbox/netbox/settings.py | cut -d\' -f2)"
echo "You are installing (or upgrading to) NetBox version ${NETBOX_VERSION}"
VIRTUALENV="$(pwd -P)/venv"
PYTHON="${PYTHON:-python3}"