Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2023-12-27 16:34:38 -05:00
commit 1f2f0860fe
38 changed files with 520 additions and 65 deletions

View File

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

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.6.7 placeholder: v3.6.8
validations: validations:
required: true required: true
- type: dropdown - 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. 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 ## 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. 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 ### 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 ### Label

View File

@ -1,6 +1,28 @@
# NetBox v3.6 # NetBox v3.6
## v3.6.8 (FUTURE) ## 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
--- ---

View File

@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from core.choices import DataSourceStatusChoices
from core.models import DataSource from core.models import DataSource
@ -33,9 +34,13 @@ class Command(BaseCommand):
for i, datasource in enumerate(datasources, start=1): for i, datasource in enumerate(datasources, start=1):
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='') self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
self.stdout.flush() self.stdout.flush()
datasource.sync() try:
self.stdout.write(datasource.get_status_display()) datasource.sync()
self.stdout.flush() 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: if len(options['name']) > 1:
self.stdout.write(f"Finished.") self.stdout.write(f"Finished.")

View File

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

View File

@ -1020,6 +1020,7 @@ class DeviceFilterSet(
Q(serial__icontains=value.strip()) | Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) |
Q(description_icontains=value.strip()) |
Q(comments__icontains=value) | Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) | Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value) Q(primary_ip6__address__startswith=value)

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

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0181_rename_device_role_device_role'), ('dcim', '0182_zero_length_cable_fix'),
] ]
operations = [ operations = [

View File

@ -7,7 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0182_devicetype_exclude_from_utilization'), ('dcim', '0183_devicetype_exclude_from_utilization'),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0183_protect_child_interfaces'), ('dcim', '0184_protect_child_interfaces'),
] ]
operations = [ operations = [

View File

@ -200,7 +200,7 @@ class Cable(PrimaryModel):
_created = self.pk is None _created = self.pk is None
# Store the given length (if any) in meters for use in database ordering # 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) self._abs_length = to_meters(self.length, self.length_unit)
else: else:
self._abs_length = None self._abs_length = None

View File

@ -277,7 +277,7 @@ class CableTraceSVG:
if cable.type: if cable.type:
# Include the cable type in the tooltip # Include the cable type in the tooltip
description.append(cable.get_type_display()) 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 # Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}') description.append(f'{cable.length} {cable.get_length_unit_display()}')
else: else:
@ -288,7 +288,7 @@ class CableTraceSVG:
description = [] description = []
if cable.type: if cable.type:
labels.append(cable.get_type_display()) 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 # Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}') labels.append(f'{cable.length} {cable.get_length_unit_display()}')

View File

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

View File

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

View File

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

View File

@ -579,8 +579,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Multiselect # Multiselect
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
filter_class = filters.MultiValueCharFilter filter_class = filters.MultiValueArrayFilter
kwargs['lookup_expr'] = 'has_key'
# Object # Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

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

View File

@ -69,21 +69,20 @@ def handle_changed_object(sender, instance, **kwargs):
return return
# Record an ObjectChange if applicable # Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'): if m2m_changed:
if m2m_changed: ObjectChange.objects.filter(
ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk,
changed_object_id=instance.pk, request_id=request.id
request_id=request.id ).update(
).update( postchange_data=instance.to_objectchange(action).postchange_data
postchange_data=instance.to_objectchange(action).postchange_data )
) else:
else: objectchange = instance.to_objectchange(action)
objectchange = instance.to_objectchange(action) if objectchange and objectchange.has_changes:
if objectchange and objectchange.has_changes: objectchange.user = request.user
objectchange.user = request.user objectchange.request_id = request.id
objectchange.request_id = request.id objectchange.save()
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save) # If this is an M2M change, update the previously queued webhook (from post_save)
queue = events_queue.get() queue = events_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( choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1', 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 # Integer filtering
@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://a.example.com', 'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com', 'cf8': 'http://a.example.com',
'cf9': 'A', 'cf9': 'A',
'cf10': ['A', 'X'], 'cf10': ['A', 'B'],
'cf11': manufacturers[0].pk, 'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk], 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}), }),
@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://b.example.com', 'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com', 'cf8': 'http://b.example.com',
'cf9': 'B', 'cf9': 'B',
'cf10': ['B', 'X'], 'cf10': ['B', 'C'],
'cf11': manufacturers[1].pk, 'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk], 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}), }),
@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://c.example.com', 'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com', 'cf8': 'http://c.example.com',
'cf9': 'C', 'cf9': 'C',
'cf10': ['C', 'X'], 'cf10': None,
'cf11': manufacturers[2].pk, 'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].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) self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self): def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3) 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): def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)

View File

@ -91,8 +91,7 @@ class CustomValidator:
def __call__(self, instance): def __call__(self, instance):
# Validate instance attributes per validation rules # Validate instance attributes per validation rules
for attr_name, rules in self.validation_rules.items(): for attr_name, rules in self.validation_rules.items():
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}" attr = self._getattr(instance, attr_name)
attr = getattr(instance, attr_name)
for descriptor, value in rules.items(): for descriptor, value in rules.items():
validator = self.get_validator(descriptor, value) validator = self.get_validator(descriptor, value)
try: try:
@ -104,6 +103,26 @@ class CustomValidator:
# Execute custom validation logic (if any) # Execute custom validation logic (if any)
self.validate(instance) 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): def get_validator(self, descriptor, value):
""" """
Instantiate and return the appropriate validator based on the descriptor given. For Instantiate and return the appropriate validator based on the descriptor given. For

View File

@ -949,6 +949,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
choices=VLANStatusChoices, choices=VLANStatusChoices,
null_value=None null_value=None
) )
available_at_site = django_filters.ModelChoiceFilter(
queryset=Site.objects.all(),
method='get_for_site'
)
available_on_device = django_filters.ModelChoiceFilter( available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
method='get_for_device' method='get_for_device'
@ -983,6 +987,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass pass
return queryset.filter(qs_filter) 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) @extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value): def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value) return queryset.get_for_device(value)

View File

@ -225,11 +225,11 @@ class VLAN(PrimaryModel):
# Validate VLAN group (if assigned) # Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site: if self.group and self.site and self.group.scope != self.site:
raise ValidationError({ raise ValidationError(
'group': _( _(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}." "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) ).format(group=self.group, scope=self.group.scope, site=self.site)
}) )
# Validate group min/max VIDs # Validate group min/max VIDs
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: 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): 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): def get_for_device(self, device):
""" """
Return all VLANs available to the specified 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}' field_name = f'primary_ip{instance.family}'
if device := Device.objects.filter(**{field_name: instance}).first(): if device := Device.objects.filter(**{field_name: instance}).first():
device.snapshot()
setattr(device, field_name, None)
device.save() device.save()
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first(): if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
virtualmachine.snapshot()
setattr(virtualmachine, field_name, None)
virtualmachine.save() 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. 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(): if device := Device.objects.filter(oob_ip=instance).first():
device.snapshot()
device.oob_ip = None
device.save() device.save()

View File

@ -1359,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'), VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'), VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3'), VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
) )
VLANGroup.objects.bulk_create(groups) VLANGroup.objects.bulk_create(groups)
@ -1415,6 +1416,9 @@ 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=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), 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 # Create one globally available VLAN
VLAN(vid=1000, name='Global VLAN'), VLAN(vid=1000, name='Global VLAN'),
) )
@ -1488,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_available_on_device(self): def test_available_on_device(self):
device_id = Device.objects.first().pk device_id = Device.objects.first().pk
params = {'available_on_device': device_id} 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): def test_available_on_virtualmachine(self):
vm_id = VirtualMachine.objects.first().pk vm_id = VirtualMachine.objects.first().pk
params = {'available_on_virtualmachine': vm_id} 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): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -659,6 +659,26 @@ class IPRangeListView(generic.ObjectListView):
class IPRangeView(generic.ObjectView): class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all() 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') @register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
class IPRangeIPAddressesView(generic.ObjectChildrenView): 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) validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
""" """
def validate(self, data): def validate(self, data):
# Remove custom fields data and tags (if any) prior to model validation
attrs = data.copy() attrs = data.copy()
# Remove custom field data (if any) prior to model validation
attrs.pop('custom_fields', None) attrs.pop('custom_fields', None)
attrs.pop('tags', None)
# Skip ManyToManyFields # Skip ManyToManyFields
for field in self.Meta.model._meta.get_fields(): m2m_values = {}
if isinstance(field, ManyToManyField): for field in self.Meta.model._meta.local_many_to_many:
attrs.pop(field.name, None) if field.name in attrs:
m2m_values[field.name] = attrs.pop(field.name)
# Run clean() on an instance of the model # Run clean() on an instance of the model
if self.instance is None: if self.instance is None:
@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
instance = self.instance instance = self.instance
for k, v in attrs.items(): for k, v in attrs.items():
setattr(instance, k, v) setattr(instance, k, v)
instance._m2m_values = m2m_values
instance.full_clean() instance.full_clean()
return data return data

View File

@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
return super().clean() 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): class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
""" """

View File

@ -556,6 +556,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
elif name in form.changed_data: elif name in form.changed_data:
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name]) 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.full_clean()
obj.save() obj.save()
updated_objects.append(obj) updated_objects.append(obj)

View File

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

View File

@ -82,6 +82,11 @@
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</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="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes
__all__ = ( __all__ = (
'ContentTypeFilter', 'ContentTypeFilter',
'MACAddressFilter', 'MACAddressFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter', 'MultiValueCharFilter',
'MultiValueDateFilter', 'MultiValueDateFilter',
'MultiValueDateTimeFilter', 'MultiValueDateTimeFilter',
@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.TimeField) 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): class MACAddressFilter(django_filters.CharFilter):
pass pass

View File

@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField):
if data is not None: if data is not None:
self.choices = [ 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 return bound_field

View File

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

View File

@ -7,6 +7,10 @@
# Python 3.8 or later. # Python 3.8 or later.
cd "$(dirname "$0")" 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" VIRTUALENV="$(pwd -P)/venv"
PYTHON="${PYTHON:-python3}" PYTHON="${PYTHON:-python3}"