Compare commits

..

5 Commits

Author SHA1 Message Date
Jeremy Stretch
ebf8f7fa1b Closes #20068: Enable defining profile attributes when importing module types 2025-12-02 16:50:59 -05:00
github-actions
922b08c0ff Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-12-02 05:02:22 +00:00
Bapths
84864fa5e1 Closes #20860: Add changlog message support for component object creation (#20898)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-12-01 17:04:21 -06:00
Jeremy Stretch
767dfccd8f Fixes #20888: Pass decimal values for min/max on latitude and longitude fields (#20892)
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
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-12-01 10:35:44 -08:00
Tom Gamull
dc4bab7477 docs: fix broken bookmarks link in model features table
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
The bookmarks link was pointing to ../features/customization.md#bookmarks
but the bookmarks section is actually in ../features/user-preferences.md#bookmarks.

This fixes the broken anchor link.
2025-11-26 15:12:52 -05:00
9 changed files with 168 additions and 471 deletions

View File

@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
| Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |

View File

@@ -5,7 +5,6 @@ from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
@@ -473,14 +472,30 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for module weight')
)
attribute_data = forms.JSONField(
label=_('Attributes'),
required=False,
help_text=_('Attribute values for the assigned profile, passed as a dictionary')
)
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'comments', 'tags'
'attribute_data', 'comments', 'tags',
]
def clean(self):
super().clean()
# Attribute data may be included only if a profile is specified
if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
# Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
self.cleaned_data['attribute_data'] = {}
class DeviceRoleImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(
@@ -1399,52 +1414,19 @@ class MACAddressImportForm(NetBoxModelImportForm):
#
class CableImportForm(NetBoxModelImportForm):
"""
CSV bulk import form for cables.
Supports dynamic parent model resolution - terminations are identified by their parent
object (device, circuit, or power panel) and termination name.
The parent field resolves to different models based on the termination type
See CABLE_PARENT_MAPPING for supported termination types.
"""
# Map cable termination content types to their parent model and lookup field.
#
# This mapping enables dynamic parent model resolution during cable CSV imports.
# Each entry maps a termination type to a tuple of (parent_content_type, accessor):
#
# Format: 'app.model': ('parent_app.ParentModel', 'accessor')
#
CABLE_PARENT_MAPPING = {
'dcim.interface': ('dcim.Device', 'name'),
'dcim.consoleport': ('dcim.Device', 'name'),
'dcim.consoleserverport': ('dcim.Device', 'name'),
'dcim.powerport': ('dcim.Device', 'name'),
'dcim.poweroutlet': ('dcim.Device', 'name'),
'dcim.frontport': ('dcim.Device', 'name'),
'dcim.rearport': ('dcim.Device', 'name'),
'circuits.circuittermination': ('circuits.Circuit', 'cid'),
'dcim.powerfeed': ('dcim.PowerPanel', 'name'),
}
# Map parent model name to (parent_field_name, termination_name_field, value_transform)
TERMINATION_FIELDS = {
'Circuit': ('circuit', 'term_side', str.upper),
'Device': ('device', 'name', None),
'PowerPanel': ('power_panel', 'name', None),
}
# Termination A
side_a_site = CSVModelChoiceField(
label=_('Side A site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent A (if any)')
help_text=_('Site of parent device A (if any)'),
)
side_a_parent = forms.CharField(
label=_('Side A parent'),
help_text=_('Device name, Circuit CID, or Power Panel name')
side_a_device = CSVModelChoiceField(
label=_('Side A device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Device name')
)
side_a_type = CSVContentTypeField(
label=_('Side A type'),
@@ -1463,11 +1445,13 @@ class CableImportForm(NetBoxModelImportForm):
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent B (if any)')
help_text=_('Site of parent device B (if any)'),
)
side_b_parent = forms.CharField(
label=_('Side B parent'),
help_text=_('Device name, Circuit CID, or Power Panel name')
side_b_device = CSVModelChoiceField(
label=_('Side B device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Device name')
)
side_b_type = CSVContentTypeField(
label=_('Side B type'),
@@ -1516,7 +1500,7 @@ class CableImportForm(NetBoxModelImportForm):
class Meta:
model = Cable
fields = [
'side_a_site', 'side_a_parent', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_parent', 'side_b_type',
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
]
@@ -1524,6 +1508,21 @@ class CableImportForm(NetBoxModelImportForm):
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
)
def _clean_side(self, side):
"""
Derive a Cable's A/B termination objects.
@@ -1532,118 +1531,31 @@ class CableImportForm(NetBoxModelImportForm):
"""
assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
content_type = self.cleaned_data.get(f'side_{side}_type')
site = self.cleaned_data.get(f'side_{side}_site')
parent_value = self.cleaned_data.get(f'side_{side}_parent')
name = self.cleaned_data.get(f'side_{side}_name')
if not parent_value or not content_type or not name: # pragma: no cover
if not device or not content_type or not name:
return None
# Get the parent model mapping from the submitted content_type
parent_map = self.CABLE_PARENT_MAPPING.get(f'{content_type.app_label}.{content_type.model}')
# This should never happen
assert parent_map, (
'Unknown cable termination content type parent mapping: '
f'{content_type.app_label}.{content_type.model}'
)
parent_content_type, parent_accessor = parent_map
parent_app_label, parent_model_name = parent_content_type.split('.')
# Get the parent model class
model = content_type.model_class()
try:
parent_ct = ContentType.objects.get(app_label=parent_app_label.lower(), model=parent_model_name.lower())
parent_model: Device | PowerPanel | Circuit = parent_ct.model_class()
except ContentType.DoesNotExist: # pragma: no cover
# This should never happen
raise AssertionError(f'Unknown cable termination parent content type: {parent_content_type}')
# Build query for parent lookup
parent_query = {parent_accessor: parent_value}
# Add site to query if provided
if site:
parent_query['site'] = site
# Look up the parent object
try:
parent_object = parent_model.objects.get(**parent_query)
except parent_model.DoesNotExist:
raise forms.ValidationError(
_('Side {side_upper}: {model_name} not found: {value}').format(
side_upper=side.upper(), model_name=parent_model.__name__, value=parent_value
)
)
except parent_model.MultipleObjectsReturned:
raise forms.ValidationError(
_('Side {side_upper}: Multiple {model_name} objects found: {value}').format(
side_upper=side.upper(), model_name=parent_model.__name__, value=parent_value
)
)
# Get the termination model class
termination_model = content_type.model_class()
# Build the query to find the termination object
field_mapping = self.TERMINATION_FIELDS.get(parent_model.__name__)
if not field_mapping: # pragma: no cover
return None
parent_field, name_field, value_transform = field_mapping
query = {parent_field: parent_object}
if value_transform:
name = value_transform(name)
if name:
query[name_field] = name
# Add site to query if provided (for site-scoped parents)
if site and parent_field in ('device', 'power_panel'):
query[f'{parent_field}__site'] = site
# Look up the termination object
try:
# Handle virtual chassis for device-based terminations
if (parent_field == 'device' and
parent_object.virtual_chassis and
parent_object.virtual_chassis.master == parent_object and
termination_model.objects.filter(**query).count() == 0):
query[f'{parent_field}__in'] = parent_object.virtual_chassis.members.all()
query.pop(parent_field, None)
termination_object = termination_model.objects.get(**query)
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = termination_model.objects.get(**query)
# Check if already connected to a cable
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(
_('Side {side_upper}: {parent} {termination} is already connected').format(
side_upper=side.upper(), parent=parent_object, termination=termination_object
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
)
)
# Circuit terminations can also be connected to provider networks
if (name_field == 'term_side' and
hasattr(termination_object, '_provider_network') and
termination_object._provider_network is not None):
raise forms.ValidationError(
_('Side {side_upper}: {parent} {termination} is already connected to a provider network').format(
side_upper=side.upper(), parent=parent_object, termination=termination_object
)
)
except termination_model.DoesNotExist:
except ObjectDoesNotExist:
raise forms.ValidationError(
_('Side {side_upper}: {model_name} not found: {parent} {name}').format(
side_upper=side.upper(),
model_name=termination_model.__name__,
parent=parent_object, name=name or '',
),
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
)
)
except termination_model.MultipleObjectsReturned: # pragma: no cover
# This should never happen
raise AssertionError('Multiple termination objects returned for query: {query}'.format(query=query))
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object

View File

@@ -1,3 +1,5 @@
import decimal
import django.core.validators
from django.db import migrations, models
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
max_digits=8,
null=True,
validators=[
django.core.validators.MinValueValidator(-90.0),
django.core.validators.MaxValueValidator(90.0),
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
],
),
),
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180.0),
django.core.validators.MaxValueValidator(180.0),
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
],
),
),
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
max_digits=8,
null=True,
validators=[
django.core.validators.MinValueValidator(-90.0),
django.core.validators.MaxValueValidator(90.0),
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
],
),
),
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180.0),
django.core.validators.MaxValueValidator(180.0),
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
],
),
),

View File

@@ -646,7 +646,10 @@ class Device(
decimal_places=6,
blank=True,
null=True,
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
validators=[
MinValueValidator(decimal.Decimal('-90.0')),
MaxValueValidator(decimal.Decimal('90.0'))
],
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
longitude = models.DecimalField(
@@ -655,7 +658,10 @@ class Device(
decimal_places=6,
blank=True,
null=True,
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
validators=[
MinValueValidator(decimal.Decimal('-180.0')),
MaxValueValidator(decimal.Decimal('180.0'))
],
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
services = GenericRelation(

View File

@@ -1,3 +1,5 @@
import decimal
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -211,7 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
decimal_places=6,
blank=True,
null=True,
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
validators=[
MinValueValidator(decimal.Decimal('-90.0')),
MaxValueValidator(decimal.Decimal('90.0'))
],
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)
longitude = models.DecimalField(
@@ -220,7 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
decimal_places=6,
blank=True,
null=True,
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
validators=[
MinValueValidator(decimal.Decimal('-180.0')),
MaxValueValidator(decimal.Decimal('180.0'))
],
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)

View File

@@ -1,9 +1,8 @@
from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
from dcim.choices import (
CableTypeChoices, DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices,
PortTypeChoices, PowerOutletStatusChoices,
DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
PowerOutletStatusChoices,
)
from dcim.forms import *
from dcim.models import *
@@ -412,204 +411,3 @@ class InterfaceTestCase(TestCase):
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
class CableImportFormTestCase(TestCase):
"""
Test cases for CableImportForm error handling and edge cases.
Note: Happy path scenarios (successful cable creation) are covered by
dcim.tests.test_views.CableTestCase which tests the bulk import view.
These tests focus on validation errors and edge cases not covered by the view tests.
"""
@classmethod
def setUpTestData(cls):
# Create sites
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
# Create manufacturer and device type
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Device Type 1',
slug='device-type-1',
)
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
# Create devices
cls.device_a1 = Device.objects.create(
name='Device-A1',
device_type=device_type,
role=role,
site=cls.site_a,
)
cls.device_a2 = Device.objects.create(
name='Device-A2',
device_type=device_type,
role=role,
site=cls.site_a,
)
# Device with same name in different site
cls.device_b_duplicate = Device.objects.create(
name='Device-A1', # Same name as device_a1
device_type=device_type,
role=role,
site=cls.site_b,
)
# Create interfaces
cls.interface_a1_eth0 = Interface.objects.create(
device=cls.device_a1,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
cls.interface_a2_eth0 = Interface.objects.create(
device=cls.device_a2,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
# Create circuit for testing circuit not found error
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
cls.circuit = Circuit.objects.create(
provider=provider,
type=circuit_type,
cid='CIRCUIT-001',
)
cls.circuit_term_a = CircuitTermination.objects.create(
circuit=cls.circuit,
term_side='A',
)
# Create provider network for testing provider network validation
cls.provider_network = ProviderNetwork.objects.create(
provider=provider,
name='Provider Network 1',
)
def test_device_not_found(self):
"""Test error when parent device is not found."""
form = CableImportForm(data={
'side_a_site': 'Site A',
'side_a_parent': 'NonexistentDevice',
'side_a_type': 'dcim.interface',
'side_a_name': 'eth0',
'side_b_site': 'Site A',
'side_b_parent': 'Device-A2',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_CAT6,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('Side A: Device not found: NonexistentDevice', str(form.errors))
def test_circuit_not_found(self):
"""Test error when circuit is not found."""
form = CableImportForm(data={
'side_a_site': None,
'side_a_parent': 'NONEXISTENT-CID',
'side_a_type': 'circuits.circuittermination',
'side_a_name': 'A',
'side_b_site': 'Site A',
'side_b_parent': 'Device-A1',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_MMF_OM4,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('Side A: Circuit not found: NONEXISTENT-CID', str(form.errors))
def test_termination_not_found(self):
"""Test error when termination is not found on parent."""
form = CableImportForm(data={
'side_a_site': 'Site A',
'side_a_parent': 'Device-A1',
'side_a_type': 'dcim.interface',
'side_a_name': 'eth999', # Nonexistent interface
'side_b_site': 'Site A',
'side_b_parent': 'Device-A2',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_CAT6,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('Side A: Interface not found', str(form.errors))
def test_termination_already_cabled(self):
"""Test error when termination is already connected to a cable."""
# Create an existing cable
existing_cable = Cable.objects.create(type=CableTypeChoices.TYPE_CAT6, status='connected')
self.interface_a1_eth0.cable = existing_cable
self.interface_a1_eth0.save()
form = CableImportForm(data={
'side_a_site': 'Site A',
'side_a_parent': 'Device-A1',
'side_a_type': 'dcim.interface',
'side_a_name': 'eth0',
'side_b_site': 'Site A',
'side_b_parent': 'Device-A2',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_CAT6,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('already connected', str(form.errors))
def test_circuit_termination_with_provider_network(self):
"""Test error when circuit termination is already connected to a provider network."""
from django.contrib.contenttypes.models import ContentType
# Connect circuit termination to provider network
circuit_term = CircuitTermination.objects.get(pk=self.circuit_term_a.pk)
pn_ct = ContentType.objects.get_for_model(ProviderNetwork)
circuit_term.termination_type = pn_ct
circuit_term.termination_id = self.provider_network.pk
circuit_term.save()
try:
form = CableImportForm(data={
'side_a_site': None,
'side_a_parent': 'CIRCUIT-001',
'side_a_type': 'circuits.circuittermination',
'side_a_name': 'A',
'side_b_site': 'Site A',
'side_b_parent': 'Device-A1',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_MMF_OM4,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('already connected to a provider network', str(form.errors))
finally:
# Clean up: remove provider network connection
circuit_term.termination_type = None
circuit_term.termination_id = None
circuit_term.save()
def test_multiple_parents_without_site(self):
"""Test error when multiple parent objects are found without site scoping."""
# Device-A1 exists in both site_a and site_b
# Try to find device without specifying site
form = CableImportForm(data={
'side_a_site': '', # Empty site - should cause multiple matches
'side_a_parent': 'Device-A1',
'side_a_type': 'dcim.interface',
'side_a_name': 'eth0',
'side_b_site': 'Site A',
'side_b_parent': 'Device-A2',
'side_b_type': 'dcim.interface',
'side_b_name': 'eth0',
'type': CableTypeChoices.TYPE_CAT6,
'status': 'connected',
})
self.assertFalse(form.is_valid())
self.assertIn('Multiple Device objects found', str(form.errors))

View File

@@ -11,7 +11,6 @@ from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
from tenancy.models import Tenant
@@ -3496,7 +3495,7 @@ class CableTestCase(
Interface(device=devices[4], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[4], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
# Device 5, Site 2
# Device 1, Site 2
Interface(device=devices[5], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@@ -3508,22 +3507,6 @@ class CableTestCase(
)
Interface.objects.bulk_create(interfaces)
ConsolePort.objects.create(device=devices[0], name='Console 1')
ConsoleServerPort.objects.create(device=devices[1], name='Console Server 1')
power_panel = PowerPanel.objects.create(site=sites[0], name='Power Panel 1')
PowerFeed.objects.create(power_panel=power_panel, name='Feed 1')
PowerPort.objects.create(device=devices[0], name='PSU1')
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='CIRCUIT-001')
circuit_terminations = (
CircuitTermination(circuit=circuit, term_side='A'),
CircuitTermination(circuit=circuit, term_side='Z'),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6)
cable1.save()
cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6)
@@ -3549,7 +3532,7 @@ class CableTestCase(
cls.csv_data = {
'default': (
"side_a_parent,side_a_type,side_a_name,side_b_parent,side_b_type,side_b_name",
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 4,dcim.interface,Interface 1,Device 5,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
@@ -3562,28 +3545,12 @@ class CableTestCase(
'site-filtering': (
# Ensure that CSV bulk import supports assigning terminations from parent devices
# that share the same device name, provided those devices belong to different sites.
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
),
'circuits': (
# Test circuit termination to interface cables
"side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
"CIRCUIT-001,circuits.circuittermination,A,Site 1,Device 4,dcim.interface,Interface 2",
"CIRCUIT-001,circuits.circuittermination,z,Site 2,Device 5,dcim.interface,Interface 2",
),
'power': (
# Test power feed to power port cables
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
"Site 1,Power Panel 1,dcim.powerfeed,Feed 1,Site 1,Device 1,dcim.powerport,PSU1",
),
'console': (
# Test console port to console server port cables
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
"Site 1,Device 1,dcim.consoleport,Console 1,Site 1,Device 2,dcim.consoleserverport,Console Server 1",
)
}

View File

@@ -559,6 +559,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
form.instance._replicated_base = hasattr(self.form, "replication_fields")
if form.is_valid():
changelog_message = form.cleaned_data.pop('changelog_message', '')
new_components = []
data = deepcopy(request.POST)
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
@@ -585,6 +586,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
# Create the new components
new_objs = []
for component_form in new_components:
# Record changelog message (if any)
if changelog_message:
component_form.instance._changelog_message = changelog_message
obj = component_form.save()
new_objs.append(obj)

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-26 05:01+0000\n"
"POT-Creation-Date: 2025-12-02 05:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1469,10 +1469,10 @@ msgstr ""
#: netbox/core/models/jobs.py:95 netbox/dcim/models/cables.py:51
#: netbox/dcim/models/device_components.py:488
#: netbox/dcim/models/device_components.py:1319
#: netbox/dcim/models/devices.py:580 netbox/dcim/models/devices.py:1196
#: netbox/dcim/models/devices.py:580 netbox/dcim/models/devices.py:1202
#: netbox/dcim/models/modules.py:210 netbox/dcim/models/power.py:94
#: netbox/dcim/models/racks.py:294 netbox/dcim/models/racks.py:677
#: netbox/dcim/models/sites.py:155 netbox/dcim/models/sites.py:273
#: netbox/dcim/models/sites.py:157 netbox/dcim/models/sites.py:281
#: netbox/ipam/models/ip.py:243 netbox/ipam/models/ip.py:529
#: netbox/ipam/models/ip.py:758 netbox/ipam/models/vlans.py:228
#: netbox/virtualization/models/clusters.py:70
@@ -1603,10 +1603,10 @@ msgstr ""
#: netbox/core/models/jobs.py:56
#: netbox/dcim/models/device_component_templates.py:44
#: netbox/dcim/models/device_components.py:53 netbox/dcim/models/devices.py:524
#: netbox/dcim/models/devices.py:1122 netbox/dcim/models/devices.py:1191
#: netbox/dcim/models/devices.py:1128 netbox/dcim/models/devices.py:1197
#: netbox/dcim/models/modules.py:32 netbox/dcim/models/power.py:38
#: netbox/dcim/models/power.py:89 netbox/dcim/models/racks.py:263
#: netbox/dcim/models/sites.py:143 netbox/extras/models/configs.py:36
#: netbox/dcim/models/sites.py:145 netbox/extras/models/configs.py:36
#: netbox/extras/models/configs.py:78 netbox/extras/models/configs.py:272
#: netbox/extras/models/customfields.py:94 netbox/extras/models/models.py:60
#: netbox/extras/models/models.py:165 netbox/extras/models/models.py:308
@@ -1637,7 +1637,7 @@ msgid "Full name of the provider"
msgstr ""
#: netbox/circuits/models/providers.py:28 netbox/dcim/models/devices.py:89
#: netbox/dcim/models/racks.py:143 netbox/dcim/models/sites.py:150
#: netbox/dcim/models/racks.py:143 netbox/dcim/models/sites.py:152
#: netbox/extras/models/models.py:474 netbox/ipam/models/asns.py:24
#: netbox/ipam/models/vlans.py:43 netbox/netbox/models/__init__.py:146
#: netbox/netbox/models/__init__.py:195 netbox/tenancy/models/tenants.py:25
@@ -3816,8 +3816,8 @@ msgstr ""
#: netbox/dcim/filtersets.py:1197 netbox/dcim/forms/filtersets.py:848
#: netbox/dcim/forms/filtersets.py:1473 netbox/dcim/forms/filtersets.py:1688
#: netbox/dcim/forms/model_forms.py:1900 netbox/dcim/models/devices.py:1292
#: netbox/dcim/models/devices.py:1312 netbox/virtualization/filtersets.py:201
#: netbox/dcim/forms/model_forms.py:1900 netbox/dcim/models/devices.py:1298
#: netbox/dcim/models/devices.py:1318 netbox/virtualization/filtersets.py:201
#: netbox/virtualization/filtersets.py:273
#: netbox/virtualization/forms/filtersets.py:178
#: netbox/virtualization/forms/filtersets.py:231
@@ -6857,12 +6857,12 @@ msgstr ""
msgid "rack face"
msgstr ""
#: netbox/dcim/models/devices.py:598 netbox/dcim/models/devices.py:1212
#: netbox/dcim/models/devices.py:598 netbox/dcim/models/devices.py:1218
#: netbox/virtualization/models/virtualmachines.py:94
msgid "primary IPv4"
msgstr ""
#: netbox/dcim/models/devices.py:606 netbox/dcim/models/devices.py:1220
#: netbox/dcim/models/devices.py:606 netbox/dcim/models/devices.py:1226
#: netbox/virtualization/models/virtualmachines.py:102
msgid "primary IPv6"
msgstr ""
@@ -6887,191 +6887,191 @@ msgstr ""
msgid "Virtual chassis master election priority"
msgstr ""
#: netbox/dcim/models/devices.py:644 netbox/dcim/models/sites.py:209
#: netbox/dcim/models/devices.py:644 netbox/dcim/models/sites.py:211
msgid "latitude"
msgstr ""
#: netbox/dcim/models/devices.py:650 netbox/dcim/models/devices.py:659
#: netbox/dcim/models/sites.py:215 netbox/dcim/models/sites.py:224
#: netbox/dcim/models/devices.py:653 netbox/dcim/models/devices.py:665
#: netbox/dcim/models/sites.py:220 netbox/dcim/models/sites.py:232
msgid "GPS coordinate in decimal format (xx.yyyyyy)"
msgstr ""
#: netbox/dcim/models/devices.py:653 netbox/dcim/models/sites.py:218
#: netbox/dcim/models/devices.py:656 netbox/dcim/models/sites.py:223
msgid "longitude"
msgstr ""
#: netbox/dcim/models/devices.py:733
#: netbox/dcim/models/devices.py:739
msgid "Device name must be unique per site."
msgstr ""
#: netbox/dcim/models/devices.py:744
#: netbox/dcim/models/devices.py:750
msgid "device"
msgstr ""
#: netbox/dcim/models/devices.py:745
#: netbox/dcim/models/devices.py:751
msgid "devices"
msgstr ""
#: netbox/dcim/models/devices.py:764
#: netbox/dcim/models/devices.py:770
#, python-brace-format
msgid "Rack {rack} does not belong to site {site}."
msgstr ""
#: netbox/dcim/models/devices.py:769
#: netbox/dcim/models/devices.py:775
#, python-brace-format
msgid "Location {location} does not belong to site {site}."
msgstr ""
#: netbox/dcim/models/devices.py:775
#: netbox/dcim/models/devices.py:781
#, python-brace-format
msgid "Rack {rack} does not belong to location {location}."
msgstr ""
#: netbox/dcim/models/devices.py:782
#: netbox/dcim/models/devices.py:788
msgid "Cannot select a rack face without assigning a rack."
msgstr ""
#: netbox/dcim/models/devices.py:786
#: netbox/dcim/models/devices.py:792
msgid "Cannot select a rack position without assigning a rack."
msgstr ""
#: netbox/dcim/models/devices.py:792
#: netbox/dcim/models/devices.py:798
msgid "Position must be in increments of 0.5 rack units."
msgstr ""
#: netbox/dcim/models/devices.py:796
#: netbox/dcim/models/devices.py:802
msgid "Must specify rack face when defining rack position."
msgstr ""
#: netbox/dcim/models/devices.py:804
#: netbox/dcim/models/devices.py:810
#, python-brace-format
msgid "A 0U device type ({device_type}) cannot be assigned to a rack position."
msgstr ""
#: netbox/dcim/models/devices.py:815
#: netbox/dcim/models/devices.py:821
msgid ""
"Child device types cannot be assigned to a rack face. This is an attribute "
"of the parent device."
msgstr ""
#: netbox/dcim/models/devices.py:822
#: netbox/dcim/models/devices.py:828
msgid ""
"Child device types cannot be assigned to a rack position. This is an "
"attribute of the parent device."
msgstr ""
#: netbox/dcim/models/devices.py:836
#: netbox/dcim/models/devices.py:842
#, python-brace-format
msgid ""
"U{position} is already occupied or does not have sufficient space to "
"accommodate this device type: {device_type} ({u_height}U)"
msgstr ""
#: netbox/dcim/models/devices.py:851
#: netbox/dcim/models/devices.py:857
#, python-brace-format
msgid "{ip} is not an IPv4 address."
msgstr ""
#: netbox/dcim/models/devices.py:863 netbox/dcim/models/devices.py:881
#: netbox/dcim/models/devices.py:869 netbox/dcim/models/devices.py:887
#, python-brace-format
msgid "The specified IP address ({ip}) is not assigned to this device."
msgstr ""
#: netbox/dcim/models/devices.py:869
#: netbox/dcim/models/devices.py:875
#, python-brace-format
msgid "{ip} is not an IPv6 address."
msgstr ""
#: netbox/dcim/models/devices.py:899
#: netbox/dcim/models/devices.py:905
#, python-brace-format
msgid ""
"The assigned platform is limited to {platform_manufacturer} device types, "
"but this device's type belongs to {devicetype_manufacturer}."
msgstr ""
#: netbox/dcim/models/devices.py:910
#: netbox/dcim/models/devices.py:916
#, python-brace-format
msgid "The assigned cluster belongs to a different site ({site})"
msgstr ""
#: netbox/dcim/models/devices.py:917
#: netbox/dcim/models/devices.py:923
#, python-brace-format
msgid "The assigned cluster belongs to a different location ({location})"
msgstr ""
#: netbox/dcim/models/devices.py:925
#: netbox/dcim/models/devices.py:931
msgid "A device assigned to a virtual chassis must have its position defined."
msgstr ""
#: netbox/dcim/models/devices.py:931
#: netbox/dcim/models/devices.py:937
#, python-brace-format
msgid ""
"Device cannot be removed from virtual chassis {virtual_chassis} because it "
"is currently designated as its master."
msgstr ""
#: netbox/dcim/models/devices.py:1127
#: netbox/dcim/models/devices.py:1133
msgid "domain"
msgstr ""
#: netbox/dcim/models/devices.py:1140 netbox/dcim/models/devices.py:1141
#: netbox/dcim/models/devices.py:1146 netbox/dcim/models/devices.py:1147
msgid "virtual chassis"
msgstr ""
#: netbox/dcim/models/devices.py:1153
#: netbox/dcim/models/devices.py:1159
#, python-brace-format
msgid "The selected master ({master}) is not assigned to this virtual chassis."
msgstr ""
#: netbox/dcim/models/devices.py:1168
#: netbox/dcim/models/devices.py:1174
#, python-brace-format
msgid ""
"Unable to delete virtual chassis {self}. There are member interfaces which "
"form a cross-chassis LAG interfaces."
msgstr ""
#: netbox/dcim/models/devices.py:1201 netbox/vpn/models/l2vpn.py:42
#: netbox/dcim/models/devices.py:1207 netbox/vpn/models/l2vpn.py:42
msgid "identifier"
msgstr ""
#: netbox/dcim/models/devices.py:1202
#: netbox/dcim/models/devices.py:1208
msgid "Numeric identifier unique to the parent device"
msgstr ""
#: netbox/dcim/models/devices.py:1230 netbox/extras/models/customfields.py:231
#: netbox/dcim/models/devices.py:1236 netbox/extras/models/customfields.py:231
#: netbox/extras/models/models.py:111 netbox/extras/models/models.py:800
#: netbox/netbox/models/__init__.py:120 netbox/netbox/models/__init__.py:155
msgid "comments"
msgstr ""
#: netbox/dcim/models/devices.py:1246
#: netbox/dcim/models/devices.py:1252
msgid "virtual device context"
msgstr ""
#: netbox/dcim/models/devices.py:1247
#: netbox/dcim/models/devices.py:1253
msgid "virtual device contexts"
msgstr ""
#: netbox/dcim/models/devices.py:1276
#: netbox/dcim/models/devices.py:1282
#, python-brace-format
msgid "{ip} is not an IPv{family} address."
msgstr ""
#: netbox/dcim/models/devices.py:1282
#: netbox/dcim/models/devices.py:1288
msgid "Primary IP address must belong to an interface on the assigned device."
msgstr ""
#: netbox/dcim/models/devices.py:1313
#: netbox/dcim/models/devices.py:1319
msgid "MAC addresses"
msgstr ""
#: netbox/dcim/models/devices.py:1345
#: netbox/dcim/models/devices.py:1351
msgid ""
"Cannot unassign MAC Address while it is designated as the primary MAC for an "
"object"
msgstr ""
#: netbox/dcim/models/devices.py:1349
#: netbox/dcim/models/devices.py:1355
msgid ""
"Cannot reassign MAC Address while it is designated as the primary MAC for an "
"object"
@@ -7378,91 +7378,91 @@ msgstr ""
msgid "The following units have already been reserved: {unit_list}"
msgstr ""
#: netbox/dcim/models/sites.py:54
#: netbox/dcim/models/sites.py:56
msgid "A top-level region with this name already exists."
msgstr ""
#: netbox/dcim/models/sites.py:64
#: netbox/dcim/models/sites.py:66
msgid "A top-level region with this slug already exists."
msgstr ""
#: netbox/dcim/models/sites.py:67
#: netbox/dcim/models/sites.py:69
msgid "region"
msgstr ""
#: netbox/dcim/models/sites.py:68
#: netbox/dcim/models/sites.py:70
msgid "regions"
msgstr ""
#: netbox/dcim/models/sites.py:110
#: netbox/dcim/models/sites.py:112
msgid "A top-level site group with this name already exists."
msgstr ""
#: netbox/dcim/models/sites.py:120
#: netbox/dcim/models/sites.py:122
msgid "A top-level site group with this slug already exists."
msgstr ""
#: netbox/dcim/models/sites.py:123
#: netbox/dcim/models/sites.py:125
msgid "site group"
msgstr ""
#: netbox/dcim/models/sites.py:124
#: netbox/dcim/models/sites.py:126
msgid "site groups"
msgstr ""
#: netbox/dcim/models/sites.py:146
#: netbox/dcim/models/sites.py:148
msgid "Full name of the site"
msgstr ""
#: netbox/dcim/models/sites.py:182 netbox/dcim/models/sites.py:286
#: netbox/dcim/models/sites.py:184 netbox/dcim/models/sites.py:294
msgid "facility"
msgstr ""
#: netbox/dcim/models/sites.py:185 netbox/dcim/models/sites.py:289
#: netbox/dcim/models/sites.py:187 netbox/dcim/models/sites.py:297
msgid "Local facility ID or description"
msgstr ""
#: netbox/dcim/models/sites.py:197
#: netbox/dcim/models/sites.py:199
msgid "physical address"
msgstr ""
#: netbox/dcim/models/sites.py:200
#: netbox/dcim/models/sites.py:202
msgid "Physical location of the building"
msgstr ""
#: netbox/dcim/models/sites.py:203
#: netbox/dcim/models/sites.py:205
msgid "shipping address"
msgstr ""
#: netbox/dcim/models/sites.py:206
#: netbox/dcim/models/sites.py:208
msgid "If different from the physical address"
msgstr ""
#: netbox/dcim/models/sites.py:248
#: netbox/dcim/models/sites.py:256
msgid "site"
msgstr ""
#: netbox/dcim/models/sites.py:249
#: netbox/dcim/models/sites.py:257
msgid "sites"
msgstr ""
#: netbox/dcim/models/sites.py:322
#: netbox/dcim/models/sites.py:330
msgid "A location with this name already exists within the specified site."
msgstr ""
#: netbox/dcim/models/sites.py:332
#: netbox/dcim/models/sites.py:340
msgid "A location with this slug already exists within the specified site."
msgstr ""
#: netbox/dcim/models/sites.py:335
#: netbox/dcim/models/sites.py:343
msgid "location"
msgstr ""
#: netbox/dcim/models/sites.py:336
#: netbox/dcim/models/sites.py:344
msgid "locations"
msgstr ""
#: netbox/dcim/models/sites.py:347
#: netbox/dcim/models/sites.py:355
#, python-brace-format
msgid "Parent location ({parent}) must belong to the same site ({site})."
msgstr ""