Compare commits

...

4 Commits

Author SHA1 Message Date
Peter
2a27e475e4 Fixes #19828: Add L2VPNTerminationType to InterfaceType (#19879)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
Co-authored-by: swoga <3697291+swoga@users.noreply.github.com>
2025-07-14 14:42:53 -05:00
Jason Novinger
44efa037cc Fixes #19800: ModuleType import supports associating ModuleTypeProfile (#19803)
* Fixes #19800: ModuleType import supports associating ModuleTypeProfile

* Fixes up ModuleTypeTestCase to include bulk import testing

Also includes an additional regression assertion.

* Address PR feedback

I ultimately left the extra asserts in for test_bulk_import_objects_with_permissionsince
since the parent test is currently only testing against number of
objects successfully imported. Will file a follow up FR to improve that
test.
2025-07-14 15:22:52 -04:00
Jeremy Stretch
6c17629159 Fixes #19841: Add white background to upgrade paths image 2025-07-14 15:08:27 -04:00
Jeremy Stretch
f13d028c98 Fixes #19827: Enforce uniqueness for device role names & slugs (#19859) 2025-07-14 09:13:44 -07:00
6 changed files with 142 additions and 19 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'tags', 'comments', 'tags'
] ]

View File

@@ -33,6 +33,7 @@ if TYPE_CHECKING:
from tenancy.graphql.types import TenantType from tenancy.graphql.types import TenantType
from users.graphql.types import UserType from users.graphql.types import UserType
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
from vpn.graphql.types import L2VPNTerminationType
from wireless.graphql.types import WirelessLANType, WirelessLinkType from wireless.graphql.types import WirelessLANType, WirelessLinkType
__all__ = ( __all__ = (
@@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

View File

@@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'name'),
name='dcim_devicerole_parent_name'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('name',),
name='dcim_devicerole_name',
violation_error_message='A top-level device role with this name already exists.'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'slug'),
name='dcim_devicerole_parent_slug'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('slug',),
name='dcim_devicerole_slug',
violation_error_message='A top-level device role with this slug already exists.'
),
),
]

View File

@@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this name already exists.")
),
models.UniqueConstraint(
fields=('parent', 'slug'),
name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this slug already exists.")
),
)
verbose_name = _('device role') verbose_name = _('device role')
verbose_name_plural = _('device roles') verbose_name_plural = _('device roles')

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import yaml import yaml
from django.test import override_settings from django.test import override_settings, tag
from django.urls import reverse from django.urls import reverse
from netaddr import EUI from netaddr import EUI
@@ -1000,18 +1000,7 @@ inventory-items:
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
# TODO: Change base class to PrimaryObjectViewTestCase class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# Blocked by absence of bulk import view for ModuleTypes
class ModuleTypeTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = ModuleType model = ModuleType
@classmethod @classmethod
@@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
ModuleType.objects.bulk_create([ module_types = ModuleType.objects.bulk_create([
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]), ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]), ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]), ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
@@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
cls.form_data = { cls.form_data = {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'model': 'Device Type X', 'model': 'Device Type X',
@@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
'part_number': '456DEF', 'part_number': '456DEF',
} }
cls.csv_data = (
"manufacturer,model,part_number,comments,profile",
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
)
cls.csv_update_data = (
"id,model",
f"{module_types[0].id},test model",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_update_objects_with_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
# run base test
super().test_bulk_update_objects_with_permission()
@tag('regression')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
# run base test
super().test_bulk_import_objects_with_permission()
# TODO: remove extra regression asserts once parent test supports testing all import fields
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
assert fan_module_type.profile == fan_module_type_profile
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_constrained_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
super().test_bulk_import_objects_with_constrained_permission()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_moduletype_consoleports(self): def test_moduletype_consoleports(self):
moduletype = ModuleType.objects.first() moduletype = ModuleType.objects.first()
@@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.csv_data = ( cls.csv_data = (
"name,slug,color", "name,slug,color",
"Device Role 4,device-role-4,ff0000", "Device Role 6,device-role-6,ff0000",
"Device Role 5,device-role-5,00ff00", "Device Role 7,device-role-7,00ff00",
"Device Role 6,device-role-6,0000ff", "Device Role 8,device-role-8,0000ff",
) )
cls.csv_update_data = ( cls.csv_update_data = (