mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-10 05:42:16 -06:00
Compare commits
14 Commits
fix_module
...
b97f6fa588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97f6fa588 | ||
|
|
598f8d034d | ||
|
|
ec13a79907 | ||
|
|
21f4036782 | ||
|
|
ce3738572c | ||
|
|
cbb979934e | ||
|
|
642d83a4c6 | ||
|
|
a06c12c6b8 | ||
|
|
60fce84c96 | ||
|
|
59afa0b41d | ||
|
|
14b246cb8a | ||
|
|
f0507d00bf | ||
|
|
77b389f105 | ||
|
|
9ae53fc232 |
@@ -20,4 +20,4 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
|||||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
|
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
|
||||||
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||||
|
|||||||
@@ -1222,6 +1222,8 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.module:
|
if self.module:
|
||||||
self.parent = self.module.module_bay
|
self.parent = self.module.module_bay
|
||||||
|
else:
|
||||||
|
self.parent = None
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -957,6 +957,11 @@ class Device(
|
|||||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||||
for component in components:
|
for component in components:
|
||||||
component.custom_field_data = cf_defaults
|
component.custom_field_data = cf_defaults
|
||||||
|
# Set denormalized references
|
||||||
|
for component in components:
|
||||||
|
component._site = self.site
|
||||||
|
component._location = self.location
|
||||||
|
component._rack = self.rack
|
||||||
components = model.objects.bulk_create(components)
|
components = model.objects.bulk_create(components)
|
||||||
# Prefetch related objects to minimize queries needed during post_save
|
# Prefetch related objects to minimize queries needed during post_save
|
||||||
prefetch_fields = get_prefetchable_fields(model)
|
prefetch_fields = get_prefetchable_fields(model)
|
||||||
|
|||||||
@@ -315,6 +315,12 @@ class Module(PrimaryModel, ConfigContextModel):
|
|||||||
for component in create_instances:
|
for component in create_instances:
|
||||||
component.custom_field_data = cf_defaults
|
component.custom_field_data = cf_defaults
|
||||||
|
|
||||||
|
# Set denormalized references
|
||||||
|
for component in create_instances:
|
||||||
|
component._site = self.device.site
|
||||||
|
component._location = self.device.location
|
||||||
|
component._rack = self.device.rack
|
||||||
|
|
||||||
if component_model is not ModuleBay:
|
if component_model is not ModuleBay:
|
||||||
component_model.objects.bulk_create(create_instances)
|
component_model.objects.bulk_create(create_instances)
|
||||||
# Emit the post_save signal for each newly created object
|
# Emit the post_save signal for each newly created object
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ def handle_location_site_change(instance, created, **kwargs):
|
|||||||
Device.objects.filter(location__in=locations).update(site=instance.site)
|
Device.objects.filter(location__in=locations).update(site=instance.site)
|
||||||
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
||||||
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
||||||
|
# Update component models for devices in these locations
|
||||||
|
for model in COMPONENT_MODELS:
|
||||||
|
model.objects.filter(device__location__in=locations).update(_site=instance.site)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Rack)
|
@receiver(post_save, sender=Rack)
|
||||||
@@ -53,6 +56,12 @@ def handle_rack_site_change(instance, created, **kwargs):
|
|||||||
"""
|
"""
|
||||||
if not created:
|
if not created:
|
||||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||||
|
# Update component models for devices in this rack
|
||||||
|
for model in COMPONENT_MODELS:
|
||||||
|
model.objects.filter(device__rack=instance).update(
|
||||||
|
_site=instance.site,
|
||||||
|
_location=instance.location,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Device)
|
@receiver(post_save, sender=Device)
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||||
create_data = [
|
create_data = [
|
||||||
{
|
{
|
||||||
'name': 'Manufacturer 4',
|
'name': 'Manufacturer 4',
|
||||||
|
|||||||
@@ -841,6 +841,32 @@ class ModuleBayTestCase(TestCase):
|
|||||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||||
self.assertEqual(nested_bay.label, 'A-21')
|
self.assertEqual(nested_bay.label, 'A-21')
|
||||||
|
|
||||||
|
@tag('regression') # #20912
|
||||||
|
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||||
|
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||||
|
device = Device.objects.first()
|
||||||
|
manufacturer = Manufacturer.objects.first()
|
||||||
|
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
|
||||||
|
bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
|
||||||
|
bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
|
||||||
|
|
||||||
|
# Install a module in bay1
|
||||||
|
module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
|
||||||
|
|
||||||
|
# Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
|
||||||
|
bay2.module = module1
|
||||||
|
bay2.save()
|
||||||
|
bay2.refresh_from_db()
|
||||||
|
self.assertEqual(bay2.parent, bay1)
|
||||||
|
self.assertEqual(bay2.module, module1)
|
||||||
|
|
||||||
|
# Clear the module assignment (return bay2 to device level) Verify parent is cleared
|
||||||
|
bay2.module = None
|
||||||
|
bay2.save()
|
||||||
|
bay2.refresh_from_db()
|
||||||
|
self.assertIsNone(bay2.parent)
|
||||||
|
self.assertIsNone(bay2.module)
|
||||||
|
|
||||||
|
|
||||||
class CableTestCase(TestCase):
|
class CableTestCase(TestCase):
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
|||||||
if snapshots:
|
if snapshots:
|
||||||
params["snapshots"] = snapshots
|
params["snapshots"] = snapshots
|
||||||
if request:
|
if request:
|
||||||
params["request"] = copy_safe_request(request)
|
# Exclude FILES - webhooks don't need uploaded files,
|
||||||
|
# which can cause pickle errors with Pillow.
|
||||||
|
params["request"] = copy_safe_request(request, include_files=False)
|
||||||
|
|
||||||
# Enqueue the task
|
# Enqueue the task
|
||||||
rq_queue.enqueue(
|
rq_queue.enqueue(
|
||||||
|
|||||||
@@ -230,10 +230,6 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
|||||||
query |= Q(**{
|
query |= Q(**{
|
||||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||||
})
|
})
|
||||||
# Don't Forget to include VLANs without a site in the filter
|
|
||||||
query |= Q(**{
|
|
||||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
|
||||||
})
|
|
||||||
|
|
||||||
if vlan_group:
|
if vlan_group:
|
||||||
query &= Q(**{
|
query &= Q(**{
|
||||||
|
|||||||
@@ -1071,14 +1071,17 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
|||||||
{
|
{
|
||||||
'name': 'VLAN Group 4',
|
'name': 'VLAN Group 4',
|
||||||
'slug': 'vlan-group-4',
|
'slug': 'vlan-group-4',
|
||||||
|
'vid_ranges': [[1, 4094]]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'VLAN Group 5',
|
'name': 'VLAN Group 5',
|
||||||
'slug': 'vlan-group-5',
|
'slug': 'vlan-group-5',
|
||||||
|
'vid_ranges': [[1, 4094]]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'VLAN Group 6',
|
'name': 'VLAN Group 6',
|
||||||
'slug': 'vlan-group-6',
|
'slug': 'vlan-group-6',
|
||||||
|
'vid_ranges': [[1, 4094]]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
|
|||||||
@@ -564,6 +564,82 @@ vlan: 102
|
|||||||
self.assertEqual(prefix.vlan.vid, 102)
|
self.assertEqual(prefix.vlan.vid, 102)
|
||||||
self.assertEqual(prefix.scope, site)
|
self.assertEqual(prefix.scope, site)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_prefix_import_with_vlan_site_multiple_vlans_same_vid(self):
|
||||||
|
"""
|
||||||
|
Test import when multiple VLANs exist with the same vid but different sites.
|
||||||
|
Ref: #20560
|
||||||
|
"""
|
||||||
|
site1 = Site.objects.get(name='Site 1')
|
||||||
|
site2 = Site.objects.get(name='Site 2')
|
||||||
|
|
||||||
|
# Create VLANs with the same vid but different sites
|
||||||
|
vlan1 = VLAN.objects.create(vid=1, name='VLAN1-Site1', site=site1)
|
||||||
|
VLAN.objects.create(vid=1, name='VLAN1-Site2', site=site2) # Create ambiguity
|
||||||
|
|
||||||
|
# Import prefix with vlan_site specified
|
||||||
|
IMPORT_DATA = f"""
|
||||||
|
prefix: 10.11.0.0/22
|
||||||
|
status: active
|
||||||
|
scope_type: dcim.site
|
||||||
|
scope_id: {site1.pk}
|
||||||
|
vlan_site: {site1.name}
|
||||||
|
vlan: 1
|
||||||
|
description: LOC02-MGMT
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add all required permissions to the test user
|
||||||
|
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'data': IMPORT_DATA,
|
||||||
|
'format': 'yaml'
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
# Verify the prefix was created with the correct VLAN
|
||||||
|
prefix = Prefix.objects.get(prefix='10.11.0.0/22')
|
||||||
|
self.assertEqual(prefix.vlan, vlan1)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_prefix_import_with_vlan_site_and_global_vlan(self):
|
||||||
|
"""
|
||||||
|
Test import when a global VLAN (no site) and site-specific VLAN exist with same vid.
|
||||||
|
When vlan_site is specified, should prefer the site-specific VLAN.
|
||||||
|
Ref: #20560
|
||||||
|
"""
|
||||||
|
site1 = Site.objects.get(name='Site 1')
|
||||||
|
|
||||||
|
# Create a global VLAN (no site) and a site-specific VLAN with the same vid
|
||||||
|
VLAN.objects.create(vid=10, name='VLAN10-Global', site=None) # Create ambiguity
|
||||||
|
vlan_site = VLAN.objects.create(vid=10, name='VLAN10-Site1', site=site1)
|
||||||
|
|
||||||
|
# Import prefix with vlan_site specified
|
||||||
|
IMPORT_DATA = f"""
|
||||||
|
prefix: 10.12.0.0/22
|
||||||
|
status: active
|
||||||
|
scope_type: dcim.site
|
||||||
|
scope_id: {site1.pk}
|
||||||
|
vlan_site: {site1.name}
|
||||||
|
vlan: 10
|
||||||
|
description: Test Site-Specific VLAN
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add all required permissions to the test user
|
||||||
|
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'data': IMPORT_DATA,
|
||||||
|
'format': 'yaml'
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
# Verify the prefix was created with the site-specific VLAN (not the global one)
|
||||||
|
prefix = Prefix.objects.get(prefix='10.12.0.0/22')
|
||||||
|
self.assertEqual(prefix.vlan, vlan_site)
|
||||||
|
|
||||||
|
|
||||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = IPRange
|
model = IPRange
|
||||||
|
|||||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
@@ -36,7 +36,6 @@ form.object-edit {
|
|||||||
// Make optgroup labels sticky when scrolling through select elements
|
// Make optgroup labels sticky when scrolling through select elements
|
||||||
select[multiple] {
|
select[multiple] {
|
||||||
optgroup {
|
optgroup {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -35,27 +35,34 @@ class NetBoxFakeRequest:
|
|||||||
# Utility functions
|
# Utility functions
|
||||||
#
|
#
|
||||||
|
|
||||||
def copy_safe_request(request):
|
def copy_safe_request(request, include_files=True):
|
||||||
"""
|
"""
|
||||||
Copy selected attributes from a request object into a new fake request object. This is needed in places where
|
Copy selected attributes from a request object into a new fake request object. This is needed in places where
|
||||||
thread safe pickling of the useful request data is needed.
|
thread safe pickling of the useful request data is needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The original request object
|
||||||
|
include_files: Whether to include request.FILES.
|
||||||
"""
|
"""
|
||||||
meta = {
|
meta = {
|
||||||
k: request.META[k]
|
k: request.META[k]
|
||||||
for k in HTTP_REQUEST_META_SAFE_COPY
|
for k in HTTP_REQUEST_META_SAFE_COPY
|
||||||
if k in request.META and isinstance(request.META[k], str)
|
if k in request.META and isinstance(request.META[k], str)
|
||||||
}
|
}
|
||||||
return NetBoxFakeRequest({
|
data = {
|
||||||
'META': meta,
|
'META': meta,
|
||||||
'COOKIES': request.COOKIES,
|
'COOKIES': request.COOKIES,
|
||||||
'POST': request.POST,
|
'POST': request.POST,
|
||||||
'GET': request.GET,
|
'GET': request.GET,
|
||||||
'FILES': request.FILES,
|
|
||||||
'user': request.user,
|
'user': request.user,
|
||||||
'method': request.method,
|
'method': request.method,
|
||||||
'path': request.path,
|
'path': request.path,
|
||||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||||
})
|
}
|
||||||
|
if include_files:
|
||||||
|
data['FILES'] = request.FILES
|
||||||
|
|
||||||
|
return NetBoxFakeRequest(data)
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request, additional_headers=()):
|
def get_client_ip(request, additional_headers=()):
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ class ModelTestCase(TestCase):
|
|||||||
elif value and type(field) is GenericForeignKey:
|
elif value and type(field) is GenericForeignKey:
|
||||||
model_dict[key] = value.pk
|
model_dict[key] = value.pk
|
||||||
|
|
||||||
|
# Handle API output
|
||||||
elif api:
|
elif api:
|
||||||
|
|
||||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||||
if type(getattr(instance, key)) in (ContentType, ObjectType):
|
if type(getattr(instance, key)) in (ContentType, ObjectType):
|
||||||
object_type = ObjectType.objects.get(pk=value)
|
object_type = ObjectType.objects.get(pk=value)
|
||||||
@@ -152,9 +152,13 @@ class ModelTestCase(TestCase):
|
|||||||
elif type(value) is IPNetwork:
|
elif type(value) is IPNetwork:
|
||||||
model_dict[key] = str(value)
|
model_dict[key] = str(value)
|
||||||
|
|
||||||
else:
|
# Normalize arrays of numeric ranges (e.g. VLAN IDs or port ranges).
|
||||||
field = instance._meta.get_field(key)
|
# DB uses canonical half-open [lo, hi) via NumericRange; API uses inclusive [lo, hi].
|
||||||
|
# Convert to inclusive pairs for stable API comparisons.
|
||||||
|
elif type(field) is ArrayField and issubclass(type(field.base_field), RangeField):
|
||||||
|
model_dict[key] = [[r.lower, r.upper - 1] for r in value]
|
||||||
|
|
||||||
|
else:
|
||||||
# Convert ArrayFields to CSV strings
|
# Convert ArrayFields to CSV strings
|
||||||
if type(field) is ArrayField:
|
if type(field) is ArrayField:
|
||||||
if getattr(field.base_field, 'choices', None):
|
if getattr(field.base_field, 'choices', None):
|
||||||
|
|||||||
Reference in New Issue
Block a user