mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-13 15:22:16 -06:00
Compare commits
19 Commits
v4.4.5
...
20378-del-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588c069ff1 | ||
|
|
3cdc6251be | ||
|
|
0e1705b870 | ||
|
|
cbf9b62f12 | ||
|
|
c429cc3638 | ||
|
|
032ed4f11c | ||
|
|
7ca4342c15 | ||
|
|
70bc1c226a | ||
|
|
6a21459ccc | ||
|
|
635de4af2e | ||
|
|
df96f7dd0f | ||
|
|
0b61d69e05 | ||
|
|
df688ce064 | ||
|
|
1a1ab2a19d | ||
|
|
80f03daad6 | ||
|
|
d04c41d0f6 | ||
|
|
1fc849eb40 | ||
|
|
bbf1f6181d | ||
|
|
729b0365e0 |
@@ -21,14 +21,6 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
types: [python]
|
types: [python]
|
||||||
- id: openapi-check
|
|
||||||
name: "Validate OpenAPI schema"
|
|
||||||
description: "Check for any unexpected changes to the OpenAPI schema"
|
|
||||||
files: api/.*\.py$
|
|
||||||
entry: scripts/verify-openapi.sh
|
|
||||||
language: system
|
|
||||||
pass_filenames: false
|
|
||||||
types: [python]
|
|
||||||
- id: mkdocs-build
|
- id: mkdocs-build
|
||||||
name: "Build documentation"
|
name: "Build documentation"
|
||||||
description: "Build the documentation with mkdocs"
|
description: "Build the documentation with mkdocs"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
|
|||||||
* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization)
|
* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization)
|
||||||
* [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage)
|
* [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage)
|
||||||
* [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4)
|
* [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4)
|
||||||
|
* [`PROTECTION_RULES`](./data-validation.md#protection_rules)
|
||||||
* [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height)
|
* [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height)
|
||||||
* [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width)
|
* [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width)
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ If `True`, the cookie employed for cross-site request forgery (CSRF) protection
|
|||||||
|
|
||||||
Default: `[]`
|
Default: `[]`
|
||||||
|
|
||||||
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
|
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://`).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
CSRF_TRUSTED_ORIGINS = (
|
CSRF_TRUSTED_ORIGINS = (
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ Four of the standard Python logging levels are supported:
|
|||||||
|
|
||||||
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
|
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
|
||||||
|
|
||||||
|
### Jobs running for Model instances
|
||||||
|
|
||||||
|
A Job can be executed for a specific instance of a Model.
|
||||||
|
To enable this functionality, the model must include the `JobsMixin`.
|
||||||
|
|
||||||
|
When enqueuing a Job, you can associate it with a particular instance by passing that instance to the `instance` parameter.
|
||||||
|
|
||||||
### Scheduled Jobs
|
### Scheduled Jobs
|
||||||
|
|
||||||
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
|
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
|
||||||
@@ -73,9 +80,10 @@ As described above, jobs can be scheduled for immediate execution or at any late
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from core.choices import JobIntervalChoices
|
from core.choices import JobIntervalChoices
|
||||||
from netbox.models import NetBoxModel
|
from netbox.models import NetBoxModel
|
||||||
|
from netbox.models.features import JobsMixin
|
||||||
from .jobs import MyTestJob
|
from .jobs import MyTestJob
|
||||||
|
|
||||||
class MyModel(NetBoxModel):
|
class MyModel(JobsMixin, NetBoxModel):
|
||||||
foo = models.CharField()
|
foo = models.CharField()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.files.storage import storages
|
from django.core.files.storage import storages
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from ..choices import ManagedFileRootPathChoices
|
from ..choices import ManagedFileRootPathChoices
|
||||||
@@ -64,9 +63,6 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse('core:managedfile', args=[self.pk])
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.file_path
|
return self.file_path
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from threading import local
|
|||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
|
from django.db.models import CASCADE
|
||||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||||
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
||||||
from django.dispatch import receiver, Signal
|
from django.dispatch import receiver, Signal
|
||||||
@@ -220,14 +221,8 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||||||
obj.snapshot() # Ensure the change record includes the "before" state
|
obj.snapshot() # Ensure the change record includes the "before" state
|
||||||
if type(relation) is ManyToManyRel:
|
if type(relation) is ManyToManyRel:
|
||||||
getattr(obj, related_field_name).remove(instance)
|
getattr(obj, related_field_name).remove(instance)
|
||||||
elif type(relation) is ManyToOneRel and relation.field.null is True:
|
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
|
||||||
setattr(obj, related_field_name, None)
|
setattr(obj, related_field_name, None)
|
||||||
# make sure the object hasn't been deleted - in case of
|
|
||||||
# deletion chaining of related objects
|
|
||||||
try:
|
|
||||||
obj.refresh_from_db()
|
|
||||||
except DoesNotExist:
|
|
||||||
continue
|
|
||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
# Enqueue the object for event processing
|
# Enqueue the object for event processing
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ from rest_framework import status
|
|||||||
|
|
||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.models import ObjectChange, ObjectType
|
from core.models import ObjectChange, ObjectType
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
|
||||||
from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
|
from dcim.models import (
|
||||||
|
Cable, CableTermination, Device, DeviceRole, DeviceType, Manufacturer, Module, ModuleBay, ModuleType, Interface,
|
||||||
|
Site,
|
||||||
|
)
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldChoiceSet, Tag
|
from extras.models import CustomField, CustomFieldChoiceSet, Tag
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
from utilities.testing.utils import create_tags, post_data
|
from utilities.testing.utils import create_tags, create_test_device, post_data
|
||||||
from utilities.testing.views import ModelViewTestCase
|
from utilities.testing.views import ModelViewTestCase
|
||||||
from dcim.models import Manufacturer
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeLogViewTest(ModelViewTestCase):
|
class ChangeLogViewTest(ModelViewTestCase):
|
||||||
@@ -622,3 +624,64 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
|
self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
|
||||||
self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
|
self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
|
||||||
self.assertEqual(objectchange.postchange_data, None)
|
self.assertEqual(objectchange.postchange_data, None)
|
||||||
|
|
||||||
|
def test_deletion_ordering(self):
|
||||||
|
"""
|
||||||
|
Check that the cascading deletion of dependent objects is recorded in the correct order.
|
||||||
|
"""
|
||||||
|
device = create_test_device('device1')
|
||||||
|
module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
|
||||||
|
module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Module Type 1')
|
||||||
|
self.add_permissions('dcim.add_module', 'dcim.add_interface', 'dcim.delete_module')
|
||||||
|
self.assertEqual(ObjectChange.objects.count(), 0) # Sanity check
|
||||||
|
|
||||||
|
# Create a new Module
|
||||||
|
data = {
|
||||||
|
'device': device.pk,
|
||||||
|
'module_bay': module_bay.pk,
|
||||||
|
'module_type': module_type.pk,
|
||||||
|
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||||
|
}
|
||||||
|
url = reverse('dcim-api:module-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
module = device.modules.first()
|
||||||
|
|
||||||
|
# Create an Interface on the Module
|
||||||
|
data = {
|
||||||
|
'device': device.pk,
|
||||||
|
'module': module.pk,
|
||||||
|
'name': 'Interface 1',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||||
|
}
|
||||||
|
url = reverse('dcim-api:interface-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
interface = device.interfaces.first()
|
||||||
|
|
||||||
|
# Delete the Module
|
||||||
|
url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
|
||||||
|
response = self.client.delete(url, **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertEqual(Module.objects.count(), 0)
|
||||||
|
self.assertEqual(Interface.objects.count(), 0)
|
||||||
|
|
||||||
|
# Verify the creation of the expected ObjectChange records. We should see four total records, in this order:
|
||||||
|
# 1. Module created
|
||||||
|
# 2. Interface created
|
||||||
|
# 3. Interface deleted
|
||||||
|
# 4. Module deleted
|
||||||
|
changes = ObjectChange.objects.order_by('time')
|
||||||
|
self.assertEqual(len(changes), 4)
|
||||||
|
self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(Module))
|
||||||
|
self.assertEqual(changes[0].changed_object_id, module.pk)
|
||||||
|
self.assertEqual(changes[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||||
|
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
|
||||||
|
self.assertEqual(changes[1].changed_object_id, interface.pk)
|
||||||
|
self.assertEqual(changes[1].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||||
|
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Interface))
|
||||||
|
self.assertEqual(changes[2].changed_object_id, interface.pk)
|
||||||
|
self.assertEqual(changes[2].action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
|
||||||
|
self.assertEqual(changes[3].changed_object_id, module.pk)
|
||||||
|
self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
|||||||
@@ -1736,6 +1736,15 @@ class CableTypeChoices(ChoiceSet):
|
|||||||
|
|
||||||
# Copper - Coaxial
|
# Copper - Coaxial
|
||||||
TYPE_COAXIAL = 'coaxial'
|
TYPE_COAXIAL = 'coaxial'
|
||||||
|
TYPE_RG_6 = 'rg-6'
|
||||||
|
TYPE_RG_8 = 'rg-8'
|
||||||
|
TYPE_RG_11 = 'rg-11'
|
||||||
|
TYPE_RG_59 = 'rg-59'
|
||||||
|
TYPE_RG_62 = 'rg-62'
|
||||||
|
TYPE_RG_213 = 'rg-213'
|
||||||
|
TYPE_LMR_100 = 'lmr-100'
|
||||||
|
TYPE_LMR_200 = 'lmr-200'
|
||||||
|
TYPE_LMR_400 = 'lmr-400'
|
||||||
|
|
||||||
# Fiber Optic - Multimode
|
# Fiber Optic - Multimode
|
||||||
TYPE_MMF = 'mmf'
|
TYPE_MMF = 'mmf'
|
||||||
@@ -1785,6 +1794,15 @@ class CableTypeChoices(ChoiceSet):
|
|||||||
_('Copper - Coaxial'),
|
_('Copper - Coaxial'),
|
||||||
(
|
(
|
||||||
(TYPE_COAXIAL, 'Coaxial'),
|
(TYPE_COAXIAL, 'Coaxial'),
|
||||||
|
(TYPE_RG_6, 'RG-6'),
|
||||||
|
(TYPE_RG_8, 'RG-8'),
|
||||||
|
(TYPE_RG_11, 'RG-11'),
|
||||||
|
(TYPE_RG_59, 'RG-59'),
|
||||||
|
(TYPE_RG_62, 'RG-62'),
|
||||||
|
(TYPE_RG_213, 'RG-213'),
|
||||||
|
(TYPE_LMR_100, 'LMR-100'),
|
||||||
|
(TYPE_LMR_200, 'LMR-200'),
|
||||||
|
(TYPE_LMR_400, 'LMR-400'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from dcim.choices import *
|
|||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import VRF, IPAddress
|
from ipam.choices import VLANQinQRoleChoices
|
||||||
|
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
|
||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
@@ -17,7 +18,7 @@ from utilities.forms.fields import (
|
|||||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
|
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
|
||||||
SlugField,
|
SlugField,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, VMInterface, VirtualMachine
|
from virtualization.models import Cluster, VirtualMachine, VMInterface
|
||||||
from wireless.choices import WirelessRoleChoices
|
from wireless.choices import WirelessRoleChoices
|
||||||
from .common import ModuleCommonForm
|
from .common import ModuleCommonForm
|
||||||
|
|
||||||
@@ -938,7 +939,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
required=False,
|
required=False,
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=mark_safe(
|
help_text=mark_safe(
|
||||||
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
|
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>"vdc1,vdc2,vdc3"</code>'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
type = CSVChoiceField(
|
type = CSVChoiceField(
|
||||||
@@ -967,7 +968,41 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
label=_('Mode'),
|
label=_('Mode'),
|
||||||
choices=InterfaceModeChoices,
|
choices=InterfaceModeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
|
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
|
||||||
|
)
|
||||||
|
vlan_group = CSVModelChoiceField(
|
||||||
|
label=_('VLAN group'),
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_('Filter VLANs available for assignment by group'),
|
||||||
|
)
|
||||||
|
untagged_vlan = CSVModelChoiceField(
|
||||||
|
label=_('Untagged VLAN'),
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
|
||||||
|
)
|
||||||
|
tagged_vlans = CSVModelMultipleChoiceField(
|
||||||
|
label=_('Tagged VLANs'),
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=mark_safe(
|
||||||
|
_(
|
||||||
|
'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
|
||||||
|
'(filtered by VLAN group). Example:'
|
||||||
|
)
|
||||||
|
+ ' <code>"100,200,300"</code>'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
qinq_svlan = CSVModelChoiceField(
|
||||||
|
label=_('Q-in-Q Service VLAN'),
|
||||||
|
queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
|
||||||
)
|
)
|
||||||
vrf = CSVModelChoiceField(
|
vrf = CSVModelChoiceField(
|
||||||
label=_('VRF'),
|
label=_('VRF'),
|
||||||
@@ -988,7 +1023,8 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
fields = (
|
fields = (
|
||||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||||
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
|
||||||
|
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@@ -1005,6 +1041,13 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||||
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
||||||
|
|
||||||
|
# Limit choices for VLANs to the assigned VLAN group
|
||||||
|
if vlan_group := data.get('vlan_group'):
|
||||||
|
params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
|
||||||
|
self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
|
||||||
|
self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
|
||||||
|
self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
|
||||||
|
|
||||||
def clean_enabled(self):
|
def clean_enabled(self):
|
||||||
# Make sure enabled is True when it's not included in the uploaded data
|
# Make sure enabled is True when it's not included in the uploaded data
|
||||||
if 'enabled' not in self.data:
|
if 'enabled' not in self.data:
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
|||||||
if instance.pk and self.cleaned_data['members']:
|
if instance.pk and self.cleaned_data['members']:
|
||||||
initial_position = self.cleaned_data.get('initial_position', 1)
|
initial_position = self.cleaned_data.get('initial_position', 1)
|
||||||
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
|
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
|
||||||
|
member.snapshot()
|
||||||
member.virtual_chassis = instance
|
member.virtual_chassis = instance
|
||||||
member.vc_position = i
|
member.vc_position = i
|
||||||
member.save()
|
member.save()
|
||||||
|
|||||||
@@ -2834,10 +2834,19 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"device,name,type,vrf.pk,poe_mode,poe_type",
|
"device,name,type,vrf.pk,poe_mode,poe_type,mode,untagged_vlan,tagged_vlans",
|
||||||
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
(
|
||||||
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
|
||||||
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
|
||||||
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
|
||||||
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
|||||||
@@ -3779,6 +3779,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
|
|||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
|
|
||||||
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
virtual_chassis.snapshot()
|
||||||
VCMemberFormSet = modelformset_factory(
|
VCMemberFormSet = modelformset_factory(
|
||||||
model=Device,
|
model=Device,
|
||||||
form=forms.DeviceVCMembershipForm,
|
form=forms.DeviceVCMembershipForm,
|
||||||
@@ -3831,9 +3832,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
|||||||
return 'dcim.change_virtualchassis'
|
return 'dcim.change_virtualchassis'
|
||||||
|
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
|
|
||||||
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
|
||||||
initial_data = {k: request.GET[k] for k in request.GET}
|
initial_data = {k: request.GET[k] for k in request.GET}
|
||||||
member_select_form = forms.VCMemberSelectForm(initial=initial_data)
|
member_select_form = forms.VCMemberSelectForm(initial=initial_data)
|
||||||
membership_form = forms.DeviceVCMembershipForm(initial=initial_data)
|
membership_form = forms.DeviceVCMembershipForm(initial=initial_data)
|
||||||
@@ -3846,20 +3845,20 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
|||||||
})
|
})
|
||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
|
|
||||||
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
|
||||||
member_select_form = forms.VCMemberSelectForm(request.POST)
|
member_select_form = forms.VCMemberSelectForm(request.POST)
|
||||||
|
|
||||||
if member_select_form.is_valid():
|
if member_select_form.is_valid():
|
||||||
|
|
||||||
device = member_select_form.cleaned_data['device']
|
device = member_select_form.cleaned_data['device']
|
||||||
|
device.snapshot()
|
||||||
device.virtual_chassis = virtual_chassis
|
device.virtual_chassis = virtual_chassis
|
||||||
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
|
data = {
|
||||||
|
'vc_position': request.POST['vc_position'],
|
||||||
|
'vc_priority': request.POST['vc_priority'],
|
||||||
|
}
|
||||||
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
|
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
|
||||||
|
|
||||||
if membership_form.is_valid():
|
if membership_form.is_valid():
|
||||||
|
|
||||||
membership_form.save()
|
membership_form.save()
|
||||||
messages.success(request, mark_safe(
|
messages.success(request, mark_safe(
|
||||||
_('Added member <a href="{url}">{device}</a>').format(
|
_('Added member <a href="{url}">{device}</a>').format(
|
||||||
@@ -3869,11 +3868,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
|||||||
|
|
||||||
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
|
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
|
||||||
return redirect(request.get_full_path())
|
return redirect(request.get_full_path())
|
||||||
|
|
||||||
return redirect(self.get_return_url(request, device))
|
return redirect(self.get_return_url(request, device))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
|
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
|
||||||
|
|
||||||
return render(request, 'dcim/virtualchassis_add_member.html', {
|
return render(request, 'dcim/virtualchassis_add_member.html', {
|
||||||
@@ -3891,7 +3888,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
|||||||
return 'dcim.change_device'
|
return 'dcim.change_device'
|
||||||
|
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
|
|
||||||
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
|
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
|
||||||
form = ConfirmationForm(initial=request.GET)
|
form = ConfirmationForm(initial=request.GET)
|
||||||
|
|
||||||
@@ -3902,7 +3898,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
|||||||
})
|
})
|
||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
|
|
||||||
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
|
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
|
||||||
form = ConfirmationForm(request.POST)
|
form = ConfirmationForm(request.POST)
|
||||||
|
|
||||||
@@ -3916,9 +3911,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
|||||||
return redirect(device.get_absolute_url())
|
return redirect(device.get_absolute_url())
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
device.snapshot()
|
||||||
devices = Device.objects.filter(pk=device.pk)
|
|
||||||
for device in devices:
|
|
||||||
device.virtual_chassis = None
|
device.virtual_chassis = None
|
||||||
device.vc_position = None
|
device.vc_position = None
|
||||||
device.vc_priority = None
|
device.vc_priority = None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -99,6 +100,35 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
|||||||
def _get_form_field(self, customfield):
|
def _get_form_field(self, customfield):
|
||||||
return customfield.to_form_field(for_csv_import=True)
|
return customfield.to_form_field(for_csv_import=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Cleans data in a form, ensuring proper handling of model fields with `null=True`.
|
||||||
|
Overrides the `clean` method from the parent form to process and sanitize cleaned
|
||||||
|
data for defined fields in the associated model.
|
||||||
|
"""
|
||||||
|
super().clean()
|
||||||
|
cleaned = self.cleaned_data
|
||||||
|
|
||||||
|
model = getattr(self._meta, "model", None)
|
||||||
|
if not model:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
for f in model._meta.get_fields():
|
||||||
|
# Only forward, DB-backed fields (skip M2M & reverse relations)
|
||||||
|
if not isinstance(f, models.Field) or not f.concrete or f.many_to_many:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if getattr(f, "null", False):
|
||||||
|
name = f.name
|
||||||
|
if name not in cleaned:
|
||||||
|
continue
|
||||||
|
val = cleaned[name]
|
||||||
|
# Only coerce empty strings; leave other types alone
|
||||||
|
if isinstance(val, str) and val.strip() == "":
|
||||||
|
cleaned[name] = None
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEditForm):
|
class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEditForm):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import logging
|
|||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.db import router
|
from django.db import router
|
||||||
from django.db.models.deletion import Collector
|
from django.db.models.deletion import CASCADE, Collector
|
||||||
|
|
||||||
logger = logging.getLogger("netbox.models.deletion")
|
logger = logging.getLogger("netbox.models.deletion")
|
||||||
|
|
||||||
|
|
||||||
class CustomCollector(Collector):
|
class CustomCollector(Collector):
|
||||||
"""
|
"""
|
||||||
Custom collector that handles GenericRelations correctly.
|
Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def collect(
|
def collect(
|
||||||
@@ -23,11 +23,15 @@ class CustomCollector(Collector):
|
|||||||
keep_parents=False,
|
keep_parents=False,
|
||||||
fail_on_restricted=True,
|
fail_on_restricted=True,
|
||||||
):
|
):
|
||||||
"""
|
# By default, Django will force the deletion of dependent objects before the parent only if the ForeignKey field
|
||||||
Override collect to first collect standard dependencies,
|
# is not nullable. We want to ensure proper ordering regardless, so if the ForeignKey has `on_delete=CASCADE`
|
||||||
then add GenericRelations to the dependency graph.
|
# applied, we set `nullable` to False when calling `collect()`.
|
||||||
"""
|
if objs and source and source_attr:
|
||||||
# Call parent collect first to get all standard dependencies
|
model = objs[0].__class__
|
||||||
|
field = model._meta.get_field(source_attr)
|
||||||
|
if field.remote_field.on_delete == CASCADE:
|
||||||
|
nullable = False
|
||||||
|
|
||||||
super().collect(
|
super().collect(
|
||||||
objs,
|
objs,
|
||||||
source=source,
|
source=source,
|
||||||
@@ -39,10 +43,8 @@ class CustomCollector(Collector):
|
|||||||
fail_on_restricted=fail_on_restricted,
|
fail_on_restricted=fail_on_restricted,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track which GenericRelations we've already processed to prevent infinite recursion
|
# Add GenericRelations to the dependency graph
|
||||||
processed_relations = set()
|
processed_relations = set()
|
||||||
|
|
||||||
# Now add GenericRelations to the dependency graph
|
|
||||||
for _, instances in list(self.data.items()):
|
for _, instances in list(self.data.items()):
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
# Get all GenericRelations for this model
|
# Get all GenericRelations for this model
|
||||||
|
|||||||
303
netbox/netbox/tests/test_forms.py
Normal file
303
netbox/netbox/tests/test_forms.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.choices import InterfaceTypeChoices
|
||||||
|
from dcim.forms import InterfaceImportForm
|
||||||
|
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
|
||||||
|
|
||||||
|
|
||||||
|
class NetBoxModelImportFormCleanTest(TestCase):
|
||||||
|
"""
|
||||||
|
Test the clean() method of NetBoxModelImportForm to ensure it properly converts
|
||||||
|
empty strings to None for nullable fields during CSV import.
|
||||||
|
Uses InterfaceImportForm as the concrete implementation to test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# Create minimal test fixtures for Interface
|
||||||
|
cls.site = Site.objects.create(name='Test Site', slug='test-site')
|
||||||
|
cls.manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
|
||||||
|
cls.device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=cls.manufacturer, model='Test Device Type', slug='test-device-type'
|
||||||
|
)
|
||||||
|
cls.device_role = DeviceRole.objects.create(name='Test Role', slug='test-role', color='ff0000')
|
||||||
|
cls.device = Device.objects.create(
|
||||||
|
name='Test Device', device_type=cls.device_type, role=cls.device_role, site=cls.site
|
||||||
|
)
|
||||||
|
# Create parent interfaces for ForeignKey testing
|
||||||
|
cls.parent_interface = Interface.objects.create(
|
||||||
|
device=cls.device, name='Parent Interface', type=InterfaceTypeChoices.TYPE_1GE_GBIC
|
||||||
|
)
|
||||||
|
cls.lag_interface = Interface.objects.create(
|
||||||
|
device=cls.device, name='LAG Interface', type=InterfaceTypeChoices.TYPE_LAG
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_charfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable CharField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 1',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'duplex': '', # nullable CharField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_integerfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable PositiveIntegerField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 2',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable PositiveIntegerField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_smallintegerfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable SmallIntegerField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 3',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'tx_power': '', # nullable SmallIntegerField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_decimalfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable DecimalField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 4',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'rf_channel_frequency': '', # nullable DecimalField
|
||||||
|
'rf_channel_width': '', # nullable DecimalField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_width'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_foreignkey(self):
|
||||||
|
"""Empty strings should convert to None for nullable ForeignKey"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 5',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'lag': '', # nullable ForeignKey
|
||||||
|
'parent': '', # nullable ForeignKey
|
||||||
|
'bridge': '', # nullable ForeignKey
|
||||||
|
'vrf': '', # nullable ForeignKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['lag'])
|
||||||
|
self.assertIsNone(form.cleaned_data['parent'])
|
||||||
|
self.assertIsNone(form.cleaned_data['bridge'])
|
||||||
|
self.assertIsNone(form.cleaned_data['vrf'])
|
||||||
|
|
||||||
|
def test_empty_string_preserved_non_nullable_charfield(self):
|
||||||
|
"""Empty strings should be preserved for non-nullable CharField (blank=True only)"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 6',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'label': '', # CharField with blank=True (not null=True)
|
||||||
|
'description': '', # CharField with blank=True (not null=True)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# label and description are NOT nullable in the model, so empty string remains
|
||||||
|
self.assertEqual(form.cleaned_data['label'], '')
|
||||||
|
self.assertEqual(form.cleaned_data['description'], '')
|
||||||
|
|
||||||
|
def test_empty_string_not_converted_for_required_fields(self):
|
||||||
|
"""Empty strings should NOT be converted for required fields"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': '', # required field, empty string should remain and cause error
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Form should be invalid because name is required
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
if form.errors:
|
||||||
|
self.assertIn('name', form.errors)
|
||||||
|
|
||||||
|
def test_non_string_none_value_preserved(self):
|
||||||
|
"""None values should be preserved (not modified)"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 7',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': None, # Already None
|
||||||
|
'tx_power': None, # Already None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
|
||||||
|
def test_non_string_numeric_values_preserved(self):
|
||||||
|
"""Numeric values (including 0) should not be modified"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 8',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': 0, # nullable PositiveIntegerField with value 0
|
||||||
|
'tx_power': 0, # nullable SmallIntegerField with value 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertEqual(form.cleaned_data['speed'], 0)
|
||||||
|
self.assertEqual(form.cleaned_data['tx_power'], 0)
|
||||||
|
|
||||||
|
def test_manytomany_fields_skipped(self):
|
||||||
|
"""ManyToMany fields should be skipped and not cause errors"""
|
||||||
|
# Interface has 'vdcs' and 'wireless_lans' as M2M fields
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 9',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
# vdcs and wireless_lans fields are M2M, handled by parent class
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
|
||||||
|
def test_fields_not_in_cleaned_data_skipped(self):
|
||||||
|
"""Fields not present in cleaned_data should be skipped gracefully"""
|
||||||
|
# Create minimal form data - some nullable fields won't be in cleaned_data
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 10',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
# lag, parent, bridge, vrf, speed, etc. not provided
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise KeyError when checking fields not in form data
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
|
||||||
|
def test_valid_string_values_preserved(self):
|
||||||
|
"""Non-empty string values should be properly converted to their target types"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 11',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '1000000', # Valid speed value (string will be converted to int)
|
||||||
|
'mtu': '1500', # Valid mtu value (string will be converted to int)
|
||||||
|
'description': 'Test description',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# speed and mtu are converted to int
|
||||||
|
self.assertEqual(form.cleaned_data['speed'], 1000000)
|
||||||
|
self.assertEqual(form.cleaned_data['mtu'], 1500)
|
||||||
|
self.assertEqual(form.cleaned_data['description'], 'Test description')
|
||||||
|
|
||||||
|
def test_multiple_nullable_fields_with_empty_strings(self):
|
||||||
|
"""Multiple nullable fields with empty strings should all convert to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 12',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable
|
||||||
|
'duplex': '', # nullable
|
||||||
|
'tx_power': '', # nullable
|
||||||
|
'vrf': '', # nullable ForeignKey
|
||||||
|
'poe_mode': '', # nullable
|
||||||
|
'poe_type': '', # nullable
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# All nullable fields should convert to None
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
self.assertIsNone(form.cleaned_data['vrf'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_mode'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_type'])
|
||||||
|
|
||||||
|
def test_mixed_nullable_and_non_nullable_empty_strings(self):
|
||||||
|
"""Combination of nullable and non-nullable fields with empty strings"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 13',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable, should become None
|
||||||
|
'label': '', # NOT nullable (blank=True only), should remain empty string
|
||||||
|
'duplex': '', # nullable, should become None
|
||||||
|
'description': '', # NOT nullable (blank=True only), should remain empty string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# Nullable fields convert to None
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
# Non-nullable fields remain empty strings
|
||||||
|
self.assertEqual(form.cleaned_data['label'], '')
|
||||||
|
self.assertEqual(form.cleaned_data['description'], '')
|
||||||
|
|
||||||
|
def test_wireless_fields_nullable(self):
|
||||||
|
"""Wireless-specific nullable fields should convert empty strings to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 14',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'rf_role': '', # nullable CharField
|
||||||
|
'rf_channel': '', # nullable CharField
|
||||||
|
'rf_channel_frequency': '', # nullable DecimalField
|
||||||
|
'rf_channel_width': '', # nullable DecimalField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_role'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_width'])
|
||||||
|
|
||||||
|
def test_poe_fields_nullable(self):
|
||||||
|
"""PoE-specific nullable fields should convert empty strings to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 15',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'poe_mode': '', # nullable CharField
|
||||||
|
'poe_type': '', # nullable CharField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_mode'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_type'])
|
||||||
|
|
||||||
|
def test_wwn_field_nullable(self):
|
||||||
|
"""WWN field (special field type) should convert empty string to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 16',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'wwn': '', # nullable WWNField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['wwn'])
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
<li class="breadcrumb-item"><a href="{% url 'core:background_queue_list' %}">{% trans 'Background Tasks' %}</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'core:background_queue_list' %}">{% trans 'Background Tasks' %}</a></li>
|
||||||
<li class="breadcrumb-item"><a href="{% url 'core:background_task_list' queue_index=queue_index status=job.get_status %}">{{ queue.name }}</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'core:background_task_list' queue_index=queue_index status=job.get_status.value %}">{{ queue.name }}</a></li>
|
||||||
{% endblock breadcrumbs %}
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
{% block title %}{% trans "Job" %} {{ job.id }}{% endblock %}
|
{% block title %}{% trans "Job" %} {{ job.id }}{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,7 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
|
|||||||
parent_pk = getattr(instance, field_name, None)
|
parent_pk = getattr(instance, field_name, None)
|
||||||
|
|
||||||
# Decrement the parent's counter by one
|
# Decrement the parent's counter by one
|
||||||
if parent_pk is not None and not hasattr(instance, "_previously_removed"):
|
if parent_pk is not None and not hasattr(instance, '_previously_removed'):
|
||||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,38 +87,48 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
|
|||||||
|
|
||||||
def connect_counters(*models):
|
def connect_counters(*models):
|
||||||
"""
|
"""
|
||||||
Register counter fields and connect post_save & post_delete signal handlers for the affected models.
|
Register counter fields and connect signal handlers for their child models.
|
||||||
|
Ensures exactly one receiver per child (sender), even when multiple counters
|
||||||
|
reference the same sender (e.g., Device).
|
||||||
"""
|
"""
|
||||||
for model in models:
|
connected = set() # child models we've already connected
|
||||||
|
|
||||||
|
for model in models:
|
||||||
# Find all CounterCacheFields on the model
|
# Find all CounterCacheFields on the model
|
||||||
counter_fields = [
|
counter_fields = [field for field in model._meta.get_fields() if isinstance(field, CounterCacheField)]
|
||||||
field for field in model._meta.get_fields() if type(field) is CounterCacheField
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in counter_fields:
|
for field in counter_fields:
|
||||||
to_model = apps.get_model(field.to_model_name)
|
to_model = apps.get_model(field.to_model_name)
|
||||||
|
|
||||||
# Register the counter in the registry
|
# Register the counter in the registry
|
||||||
change_tracking_fields = registry['counter_fields'][to_model]
|
change_tracking_fields = registry['counter_fields'][to_model]
|
||||||
change_tracking_fields[f"{field.to_field_name}_id"] = field.name
|
change_tracking_fields[f'{field.to_field_name}_id'] = field.name
|
||||||
|
|
||||||
|
# Connect signals once per child model
|
||||||
|
if to_model in connected:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ensure dispatch_uid is unique per model (sender), not per field
|
||||||
|
uid_base = f'countercache.{to_model._meta.label_lower}'
|
||||||
|
|
||||||
# Connect the post_save and post_delete handlers
|
# Connect the post_save and post_delete handlers
|
||||||
post_save.connect(
|
post_save.connect(
|
||||||
post_save_receiver,
|
post_save_receiver,
|
||||||
sender=to_model,
|
sender=to_model,
|
||||||
weak=False,
|
weak=False,
|
||||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
dispatch_uid=f'{uid_base}.post_save',
|
||||||
)
|
)
|
||||||
pre_delete.connect(
|
pre_delete.connect(
|
||||||
pre_delete_receiver,
|
pre_delete_receiver,
|
||||||
sender=to_model,
|
sender=to_model,
|
||||||
weak=False,
|
weak=False,
|
||||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
dispatch_uid=f'{uid_base}.pre_delete',
|
||||||
)
|
)
|
||||||
post_delete.connect(
|
post_delete.connect(
|
||||||
post_delete_receiver,
|
post_delete_receiver,
|
||||||
sender=to_model,
|
sender=to_model,
|
||||||
weak=False,
|
weak=False,
|
||||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
dispatch_uid=f'{uid_base}.post_delete',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
connected.add(to_model)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from dcim.choices import InterfaceModeChoices
|
from dcim.choices import InterfaceModeChoices
|
||||||
from dcim.forms.mixins import ScopedImportForm
|
from dcim.forms.mixins import ScopedImportForm
|
||||||
from dcim.models import Device, DeviceRole, Platform, Site
|
from dcim.models import Device, DeviceRole, Platform, Site
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import VRF
|
from ipam.choices import VLANQinQRoleChoices
|
||||||
|
from ipam.models import VLAN, VRF, VLANGroup
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
|
from utilities.forms.fields import (
|
||||||
|
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField,
|
||||||
|
SlugField,
|
||||||
|
)
|
||||||
from virtualization.choices import *
|
from virtualization.choices import *
|
||||||
from virtualization.models import *
|
from virtualization.models import *
|
||||||
|
|
||||||
@@ -158,20 +163,54 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
|||||||
queryset=VMInterface.objects.all(),
|
queryset=VMInterface.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Parent interface')
|
help_text=_('Parent interface'),
|
||||||
)
|
)
|
||||||
bridge = CSVModelChoiceField(
|
bridge = CSVModelChoiceField(
|
||||||
label=_('Bridge'),
|
label=_('Bridge'),
|
||||||
queryset=VMInterface.objects.all(),
|
queryset=VMInterface.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Bridged interface')
|
help_text=_('Bridged interface'),
|
||||||
)
|
)
|
||||||
mode = CSVChoiceField(
|
mode = CSVChoiceField(
|
||||||
label=_('Mode'),
|
label=_('Mode'),
|
||||||
choices=InterfaceModeChoices,
|
choices=InterfaceModeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
|
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
|
||||||
|
)
|
||||||
|
vlan_group = CSVModelChoiceField(
|
||||||
|
label=_('VLAN group'),
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_('Filter VLANs available for assignment by group'),
|
||||||
|
)
|
||||||
|
untagged_vlan = CSVModelChoiceField(
|
||||||
|
label=_('Untagged VLAN'),
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
|
||||||
|
)
|
||||||
|
tagged_vlans = CSVModelMultipleChoiceField(
|
||||||
|
label=_('Tagged VLANs'),
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=mark_safe(
|
||||||
|
_(
|
||||||
|
'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
|
||||||
|
'(filtered by VLAN group). Example:'
|
||||||
|
)
|
||||||
|
+ ' <code>"100,200,300"</code>'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
qinq_svlan = CSVModelChoiceField(
|
||||||
|
label=_('Q-in-Q Service VLAN'),
|
||||||
|
queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
|
||||||
|
required=False,
|
||||||
|
to_field_name='vid',
|
||||||
|
help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
|
||||||
)
|
)
|
||||||
vrf = CSVModelChoiceField(
|
vrf = CSVModelChoiceField(
|
||||||
label=_('VRF'),
|
label=_('VRF'),
|
||||||
@@ -185,7 +224,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
|||||||
model = VMInterface
|
model = VMInterface
|
||||||
fields = (
|
fields = (
|
||||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
|
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
|
||||||
'vrf', 'tags'
|
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'tags'
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@@ -200,6 +239,13 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
|||||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||||
|
|
||||||
|
# Limit choices for VLANs to the assigned VLAN group
|
||||||
|
if vlan_group := data.get('vlan_group'):
|
||||||
|
params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
|
||||||
|
self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
|
||||||
|
self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
|
||||||
|
self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
|
||||||
|
|
||||||
def clean_enabled(self):
|
def clean_enabled(self):
|
||||||
# Make sure enabled is True when it's not included in the uploaded data
|
# Make sure enabled is True when it's not included in the uploaded data
|
||||||
if 'enabled' not in self.data:
|
if 'enabled' not in self.data:
|
||||||
|
|||||||
@@ -395,10 +395,19 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"virtual_machine,name,vrf.pk",
|
"virtual_machine,name,vrf.pk,mode,untagged_vlan,tagged_vlans",
|
||||||
f"Virtual Machine 2,Interface 4,{vrfs[0].pk}",
|
(
|
||||||
f"Virtual Machine 2,Interface 5,{vrfs[0].pk}",
|
f"Virtual Machine 2,Interface 4,{vrfs[0].pk},"
|
||||||
f"Virtual Machine 2,Interface 6,{vrfs[0].pk}",
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Virtual Machine 2,Interface 5,{vrfs[0].pk},"
|
||||||
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Virtual Machine 2,Interface 6,{vrfs[0].pk},"
|
||||||
|
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
|||||||
Reference in New Issue
Block a user