Compare commits

..

2 Commits

Author SHA1 Message Date
Jeremy Stretch
b02d9d54b7 Misc cleanup 2026-04-09 13:55:37 -04:00
Jeremy Stretch
6f135699e6 Closes #21783: Fix support for bulk import of cables connected to power feeds 2026-04-09 13:43:41 -04:00
6 changed files with 229 additions and 329 deletions

View File

@@ -1409,8 +1409,16 @@ class CableImportForm(PrimaryModelImportForm):
side_a_device = CSVModelChoiceField(
label=_('Side A device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Device name')
help_text=_('Device name (for device component terminations)')
)
side_a_power_panel = CSVModelChoiceField(
label=_('Side A power panel'),
queryset=PowerPanel.objects.all(),
required=False,
to_field_name='name',
help_text=_('Power panel name (for power feed terminations)')
)
side_a_type = CSVContentTypeField(
label=_('Side A type'),
@@ -1434,8 +1442,16 @@ class CableImportForm(PrimaryModelImportForm):
side_b_device = CSVModelChoiceField(
label=_('Side B device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Device name')
help_text=_('Device name (for device component terminations)')
)
side_b_power_panel = CSVModelChoiceField(
label=_('Side B power panel'),
queryset=PowerPanel.objects.all(),
required=False,
to_field_name='name',
help_text=_('Power panel name (for power feed terminations)')
)
side_b_type = CSVContentTypeField(
label=_('Side B type'),
@@ -1490,8 +1506,9 @@ class CableImportForm(PrimaryModelImportForm):
class Meta:
model = Cable
fields = [
'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', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
'side_a_site', 'side_a_device', 'side_a_power_panel', 'side_a_type', 'side_a_name',
'side_b_site', 'side_b_device', 'side_b_power_panel', 'side_b_type', 'side_b_name',
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
'description', 'owner', 'comments', 'tags',
]
@@ -1501,16 +1518,22 @@ class CableImportForm(PrimaryModelImportForm):
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}
side_a_parent_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
**side_a_parent_params
)
self.fields['side_a_power_panel'].queryset = self.fields['side_a_power_panel'].queryset.filter(
**side_a_parent_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}
side_b_parent_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
**side_b_parent_params
)
self.fields['side_b_power_panel'].queryset = self.fields['side_b_power_panel'].queryset.filter(
**side_b_parent_params
)
def _clean_side(self, side):
@@ -1522,33 +1545,57 @@ class CableImportForm(PrimaryModelImportForm):
assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
power_panel = self.cleaned_data.get(f'side_{side}_power_panel')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name:
if not content_type or not name:
return None
model = content_type.model_class()
try:
if (
device.virtual_chassis and
device.virtual_chassis.master == device and
not model.objects.filter(device=device, name=name).exists()
):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
# PowerFeed terminations reference a PowerPanel, not a Device
if content_type.model == 'powerfeed':
if not power_panel:
return None
try:
termination_object = model.objects.get(power_panel=power_panel, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(
_("Side {side_upper}: {power_panel} {termination_object} is already connected").format(
side_upper=side.upper(), power_panel=power_panel, termination_object=termination_object
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
_("{side_upper} side termination not found: {power_panel} {name}").format(
side_upper=side.upper(), power_panel=power_panel, name=name
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
else:
if not device:
return None
try:
if (
device.virtual_chassis and
device.virtual_chassis.master == device and
not model.objects.filter(device=device, name=name).exists()
):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
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}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
)
)
)
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object

View File

@@ -3603,6 +3603,21 @@ class CableTestCase(
cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6)
cable3.save()
# Power panel, power feeds, and power ports for powerfeed-to-powerport cable import tests
power_panel = PowerPanel.objects.create(site=sites[0], name='Power Panel 1')
power_feeds = (
PowerFeed(name='Power Feed 1', power_panel=power_panel),
PowerFeed(name='Power Feed 2', power_panel=power_panel),
PowerFeed(name='Power Feed 3', power_panel=power_panel),
)
PowerFeed.objects.bulk_create(power_feeds)
power_ports = (
PowerPort(device=devices[3], name='Power Port 1'),
PowerPort(device=devices[3], name='Power Port 2'),
PowerPort(device=devices[3], name='Power Port 3'),
)
PowerPort.objects.bulk_create(power_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
@@ -3640,7 +3655,14 @@ class CableTestCase(
"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",
)
),
'powerfeed-to-powerport': (
# Ensure that powerfeed-to-powerport cables can be imported via CSV using side_a_power_panel
"side_a_power_panel,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Power Panel 1,dcim.powerfeed,Power Feed 1,Device 4,dcim.powerport,Power Port 1",
"Power Panel 1,dcim.powerfeed,Power Feed 2,Device 4,dcim.powerport,Power Port 2",
"Power Panel 1,dcim.powerfeed,Power Feed 3,Device 4,dcim.powerport,Power Port 3",
),
}
cls.csv_update_data = (

View File

@@ -1,15 +1,10 @@
import io
import tempfile
from pathlib import Path
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.core.files.storage import Storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import ValidationError
from django.test import TestCase, tag
from PIL import Image
from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
@@ -19,50 +14,10 @@ from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class OverwriteStyleMemoryStorage(Storage):
"""
In-memory storage that mimics overwrite-style backends by returning the
incoming name unchanged from get_available_name().
"""
def __init__(self):
self.files = {}
def _open(self, name, mode='rb'):
return ContentFile(self.files[name], name=name)
def _save(self, name, content):
self.files[name] = content.read()
return name
def delete(self, name):
self.files.pop(name, None)
def exists(self, name):
return name in self.files
def get_available_name(self, name, max_length=None):
return name
def get_alternative_name(self, file_root, file_ext):
return f'{file_root}_sdmmer4{file_ext}'
def listdir(self, path):
return [], list(self.files)
def size(self, name):
return len(self.files[name])
def url(self, name):
return f'https://example.invalid/{name}'
class ImageAttachmentTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
cls.ct_site = ContentType.objects.get_by_natural_key('dcim', 'site')
cls.site = Site.objects.create(name='Site 1')
cls.image_content = b''
def _stub_image_attachment(self, object_id, image_filename, name=None):
@@ -86,15 +41,6 @@ class ImageAttachmentTests(TestCase):
)
return ia
def _uploaded_png(self, filename):
image = io.BytesIO()
Image.new('RGB', (1, 1)).save(image, format='PNG')
return SimpleUploadedFile(
name=filename,
content=image.getvalue(),
content_type='image/png',
)
def test_filename_strips_expected_prefix(self):
"""
Tests that the filename of the image attachment is stripped of the expected
@@ -143,37 +89,6 @@ class ImageAttachmentTests(TestCase):
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
self.assertEqual('file.png', str(ia))
def test_duplicate_uploaded_names_get_suffixed_with_overwrite_style_storage(self):
storage = OverwriteStyleMemoryStorage()
field = ImageAttachment._meta.get_field('image')
with patch.object(field, 'storage', storage):
first = ImageAttachment(
object_type=self.ct_site,
object_id=self.site.pk,
image=self._uploaded_png('action-buttons.png'),
)
first.save()
second = ImageAttachment(
object_type=self.ct_site,
object_id=self.site.pk,
image=self._uploaded_png('action-buttons.png'),
)
second.save()
base_name = f'image-attachments/site_{self.site.pk}_action-buttons.png'
suffixed_name = f'image-attachments/site_{self.site.pk}_action-buttons_sdmmer4.png'
self.assertEqual(first.image.name, base_name)
self.assertEqual(second.image.name, suffixed_name)
self.assertNotEqual(first.image.name, second.image.name)
self.assertEqual(first.filename, 'action-buttons.png')
self.assertEqual(second.filename, 'action-buttons_sdmmer4.png')
self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
class TagTest(TestCase):

View File

@@ -1,12 +1,10 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType
from django.core.files.storage import Storage
from django.test import TestCase
from extras.models import ExportTemplate, ImageAttachment
from extras.utils import _build_image_attachment_path, filename_from_model, image_upload
from extras.models import ExportTemplate
from extras.utils import filename_from_model, image_upload
from tenancy.models import ContactGroup, TenantGroup
from wireless.models import WirelessLANGroup
@@ -24,25 +22,6 @@ class FilenameFromModelTests(TestCase):
self.assertEqual(filename_from_model(model), expected)
class OverwriteStyleStorage(Storage):
"""
Mimic an overwrite-style backend (for example, S3 with file_overwrite=True),
where get_available_name() returns the incoming name unchanged.
"""
def __init__(self, existing_names=None):
self.existing_names = set(existing_names or [])
def exists(self, name):
return name in self.existing_names
def get_available_name(self, name, max_length=None):
return name
def get_alternative_name(self, file_root, file_ext):
return f'{file_root}_sdmmer4{file_ext}'
class ImageUploadTests(TestCase):
@classmethod
def setUpTestData(cls):
@@ -52,18 +31,16 @@ class ImageUploadTests(TestCase):
def _stub_instance(self, object_id=12, name=None):
"""
Creates a minimal stub for use with image attachment path generation.
Creates a minimal stub for use with the `image_upload()` function.
This method generates an instance of `SimpleNamespace` containing a set
of attributes required to simulate the expected input for the
`image_upload()` method.
It is designed to simplify testing or processing by providing a
lightweight representation of an object.
"""
return SimpleNamespace(object_type=self.ct_rack, object_id=object_id, name=name)
def _bound_instance(self, *, storage, object_id=12, name=None, max_length=100):
return SimpleNamespace(
object_type=self.ct_rack,
object_id=object_id,
name=name,
image=SimpleNamespace(field=SimpleNamespace(storage=storage, max_length=max_length)),
)
def _second_segment(self, path: str):
"""
Extracts and returns the portion of the input string after the
@@ -76,7 +53,7 @@ class ImageUploadTests(TestCase):
Tests handling of a Windows file path with a fake directory and extension.
"""
inst = self._stub_instance(name=None)
path = _build_image_attachment_path(inst, r'C:\fake_path\MyPhoto.JPG')
path = image_upload(inst, r'C:\fake_path\MyPhoto.JPG')
# Base directory and single-level path
seg2 = self._second_segment(path)
self.assertTrue(path.startswith('image-attachments/rack_12_'))
@@ -90,7 +67,7 @@ class ImageUploadTests(TestCase):
create subdirectories.
"""
inst = self._stub_instance(name='5/31/23')
path = _build_image_attachment_path(inst, 'image.png')
path = image_upload(inst, 'image.png')
seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_'))
self.assertNotIn('/', seg2)
@@ -103,7 +80,7 @@ class ImageUploadTests(TestCase):
into a single directory name without creating subdirectories.
"""
inst = self._stub_instance(name=r'5\31\23')
path = _build_image_attachment_path(inst, 'image_name.png')
path = image_upload(inst, 'image_name.png')
seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_'))
@@ -116,7 +93,7 @@ class ImageUploadTests(TestCase):
Tests the output path format generated by the `image_upload` function.
"""
inst = self._stub_instance(object_id=99, name='label')
path = _build_image_attachment_path(inst, 'a.webp')
path = image_upload(inst, 'a.webp')
# The second segment must begin with "rack_99_"
seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_99_'))
@@ -128,7 +105,7 @@ class ImageUploadTests(TestCase):
is omitted.
"""
inst = self._stub_instance(name='test')
path = _build_image_attachment_path(inst, 'document.txt')
path = image_upload(inst, 'document.txt')
seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_test'))
@@ -144,7 +121,7 @@ class ImageUploadTests(TestCase):
# Suppose the instance name has surrounding whitespace and
# extra slashes.
inst = self._stub_instance(name=' my/complex\\name ')
path = _build_image_attachment_path(inst, 'irrelevant.png')
path = image_upload(inst, 'irrelevant.png')
# The output should be flattened and sanitized.
# We expect the name to be transformed into a valid filename without
@@ -164,7 +141,7 @@ class ImageUploadTests(TestCase):
for name in ['2025/09/12', r'2025\09\12']:
with self.subTest(name=name):
inst = self._stub_instance(name=name)
path = _build_image_attachment_path(inst, 'x.jpeg')
path = image_upload(inst, 'x.jpeg')
seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_'))
self.assertNotIn('/', seg2)
@@ -177,49 +154,7 @@ class ImageUploadTests(TestCase):
SuspiciousFileOperation, the fallback default is used.
"""
inst = self._stub_instance(name=' ')
path = _build_image_attachment_path(inst, 'sample.png')
path = image_upload(inst, 'sample.png')
# Expect the fallback name 'unnamed' to be used.
self.assertIn('unnamed', path)
self.assertTrue(path.startswith('image-attachments/rack_12_'))
def test_image_upload_preserves_original_name_when_available(self):
inst = self._bound_instance(
storage=OverwriteStyleStorage(),
name='action-buttons',
)
path = image_upload(inst, 'action-buttons.png')
self.assertEqual(path, 'image-attachments/rack_12_action-buttons.png')
def test_image_upload_uses_base_collision_handling_with_overwrite_style_storage(self):
inst = self._bound_instance(
storage=OverwriteStyleStorage(existing_names={'image-attachments/rack_12_action-buttons.png'}),
name='action-buttons',
)
path = image_upload(inst, 'action-buttons.png')
self.assertEqual(
path,
'image-attachments/rack_12_action-buttons_sdmmer4.png',
)
def test_image_field_generate_filename_uses_image_upload_collision_handling(self):
field = ImageAttachment._meta.get_field('image')
instance = ImageAttachment(
object_type=self.ct_rack,
object_id=12,
)
with patch.object(
field,
'storage',
OverwriteStyleStorage(existing_names={'image-attachments/rack_12_action-buttons.png'}),
):
path = field.generate_filename(instance, 'action-buttons.png')
self.assertEqual(
path,
'image-attachments/rack_12_action-buttons_sdmmer4.png',
)

View File

@@ -2,7 +2,7 @@ import importlib
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
from django.core.files.storage import Storage, default_storage
from django.core.files.storage import default_storage
from django.core.files.utils import validate_file_name
from django.db import models
from django.db.models import Q
@@ -67,13 +67,15 @@ def is_taggable(obj):
return False
def _build_image_attachment_path(instance, filename, *, storage=default_storage):
def image_upload(instance, filename):
"""
Build a deterministic relative path for an image attachment.
Return a path for uploading image attachments.
- Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg)
- Uses the instance.name if provided (sanitized to a *basename*, no ext)
- Prefixes with a machine-friendly identifier
Note: Relies on Django's default_storage utility.
"""
upload_dir = 'image-attachments'
default_filename = 'unnamed'
@@ -90,38 +92,22 @@ def _build_image_attachment_path(instance, filename, *, storage=default_storage)
# Rely on Django's get_valid_filename to perform sanitization.
stem = (instance.name or file_path.stem).strip()
try:
safe_stem = storage.get_valid_name(stem)
safe_stem = default_storage.get_valid_name(stem)
except SuspiciousFileOperation:
safe_stem = default_filename
# Append the uploaded extension only if it's an allowed image type
final_name = f'{safe_stem}.{ext}' if ext in allowed_img_extensions else safe_stem
final_name = f"{safe_stem}.{ext}" if ext in allowed_img_extensions else safe_stem
# Create a machine-friendly prefix from the instance
prefix = f'{instance.object_type.model}_{instance.object_id}'
name_with_path = f'{upload_dir}/{prefix}_{final_name}'
prefix = f"{instance.object_type.model}_{instance.object_id}"
name_with_path = f"{upload_dir}/{prefix}_{final_name}"
# Validate the generated relative path (blocks absolute/traversal)
validate_file_name(name_with_path, allow_relative_path=True)
return name_with_path
def image_upload(instance, filename):
"""
Return a relative upload path for an image attachment, applying Django's
usual suffix-on-collision behavior regardless of storage backend.
"""
field = instance.image.field
name_with_path = _build_image_attachment_path(instance, filename, storage=field.storage)
# Intentionally call Django's base Storage implementation here. Some
# backends override get_available_name() to reuse the incoming name
# unchanged, but we want Django's normal suffix-on-collision behavior
# while still dispatching exists() / get_alternative_name() to the
# configured storage instance.
return Storage.get_available_name(field.storage, name_with_path, max_length=field.max_length)
def is_script(obj):
"""
Returns True if the object is a Script or Report.

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-10 05:39+0000\n"
"POT-Creation-Date: 2026-04-08 05:31+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"
@@ -1247,13 +1247,13 @@ msgstr ""
#: netbox/circuits/models/base.py:18 netbox/dcim/models/cables.py:81
#: netbox/dcim/models/device_component_templates.py:328
#: netbox/dcim/models/device_component_templates.py:571
#: netbox/dcim/models/device_component_templates.py:644
#: netbox/dcim/models/device_component_templates.py:563
#: netbox/dcim/models/device_component_templates.py:636
#: netbox/dcim/models/device_components.py:605
#: netbox/dcim/models/device_components.py:1188
#: netbox/dcim/models/device_components.py:1236
#: netbox/dcim/models/device_components.py:1387
#: netbox/dcim/models/devices.py:394 netbox/dcim/models/racks.py:234
#: netbox/dcim/models/devices.py:385 netbox/dcim/models/racks.py:234
#: netbox/extras/models/tags.py:30
msgid "color"
msgstr ""
@@ -1281,8 +1281,8 @@ msgstr ""
#: netbox/core/models/jobs.py:95 netbox/dcim/models/cables.py:57
#: netbox/dcim/models/device_components.py:576
#: netbox/dcim/models/device_components.py:1426
#: netbox/dcim/models/devices.py:598 netbox/dcim/models/devices.py:1227
#: netbox/dcim/models/modules.py:227 netbox/dcim/models/power.py:95
#: netbox/dcim/models/devices.py:589 netbox/dcim/models/devices.py:1218
#: netbox/dcim/models/modules.py:219 netbox/dcim/models/power.py:95
#: netbox/dcim/models/racks.py:301 netbox/dcim/models/racks.py:685
#: netbox/dcim/models/sites.py:163 netbox/dcim/models/sites.py:287
#: netbox/ipam/models/ip.py:246 netbox/ipam/models/ip.py:548
@@ -1414,8 +1414,8 @@ msgstr ""
#: netbox/circuits/models/providers.py:98 netbox/core/models/data.py:40
#: netbox/core/models/jobs.py:56
#: netbox/dcim/models/device_component_templates.py:55
#: netbox/dcim/models/device_components.py:57 netbox/dcim/models/devices.py:542
#: netbox/dcim/models/devices.py:1153 netbox/dcim/models/devices.py:1222
#: netbox/dcim/models/device_components.py:57 netbox/dcim/models/devices.py:533
#: netbox/dcim/models/devices.py:1144 netbox/dcim/models/devices.py:1213
#: netbox/dcim/models/modules.py:35 netbox/dcim/models/power.py:39
#: netbox/dcim/models/power.py:90 netbox/dcim/models/racks.py:270
#: netbox/dcim/models/sites.py:151 netbox/extras/models/configs.py:37
@@ -2281,8 +2281,8 @@ msgstr ""
#: netbox/dcim/models/device_component_templates.py:256
#: netbox/dcim/models/device_component_templates.py:321
#: netbox/dcim/models/device_component_templates.py:412
#: netbox/dcim/models/device_component_templates.py:566
#: netbox/dcim/models/device_component_templates.py:639
#: netbox/dcim/models/device_component_templates.py:558
#: netbox/dcim/models/device_component_templates.py:631
#: netbox/dcim/models/device_components.py:402
#: netbox/dcim/models/device_components.py:429
#: netbox/dcim/models/device_components.py:460
@@ -3710,8 +3710,8 @@ msgstr ""
#: netbox/dcim/filtersets.py:1324 netbox/dcim/forms/filtersets.py:920
#: netbox/dcim/forms/filtersets.py:1634 netbox/dcim/forms/filtersets.py:1979
#: netbox/dcim/forms/model_forms.py:1941 netbox/dcim/models/devices.py:1322
#: netbox/dcim/models/devices.py:1345 netbox/dcim/ui/panels.py:366
#: netbox/dcim/forms/model_forms.py:1941 netbox/dcim/models/devices.py:1313
#: netbox/dcim/models/devices.py:1336 netbox/dcim/ui/panels.py:366
#: netbox/dcim/ui/panels.py:513 netbox/virtualization/filtersets.py:230
#: netbox/virtualization/filtersets.py:318
#: netbox/virtualization/forms/filtersets.py:193
@@ -4340,7 +4340,7 @@ msgstr ""
msgid "Chassis"
msgstr ""
#: netbox/dcim/forms/bulk_edit.py:617 netbox/dcim/models/devices.py:399
#: netbox/dcim/forms/bulk_edit.py:617 netbox/dcim/models/devices.py:390
#: netbox/dcim/tables/devices.py:76 netbox/dcim/ui/panels.py:144
msgid "VM role"
msgstr ""
@@ -5543,7 +5543,7 @@ msgstr ""
msgid "Profile & Attributes"
msgstr ""
#: netbox/dcim/forms/model_forms.py:623 netbox/dcim/models/devices.py:588
#: netbox/dcim/forms/model_forms.py:623 netbox/dcim/models/devices.py:579
msgid "The lowest-numbered unit occupied by the device"
msgstr ""
@@ -6019,91 +6019,91 @@ msgstr ""
msgid "Rear port ({rear_port}) must belong to the same device type"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:575
#: netbox/dcim/models/device_component_templates.py:648
#: netbox/dcim/models/device_component_templates.py:567
#: netbox/dcim/models/device_component_templates.py:640
#: netbox/dcim/models/device_components.py:1192
#: netbox/dcim/models/device_components.py:1240
msgid "positions"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:596
#: netbox/dcim/models/device_component_templates.py:588
msgid "front port template"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:597
#: netbox/dcim/models/device_component_templates.py:589
msgid "front port templates"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:608
#: netbox/dcim/models/device_component_templates.py:600
#, python-brace-format
msgid ""
"The number of positions cannot be less than the number of mapped rear port "
"templates ({count})"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:659
#: netbox/dcim/models/device_component_templates.py:651
msgid "rear port template"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:660
#: netbox/dcim/models/device_component_templates.py:652
msgid "rear port templates"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:671
#: netbox/dcim/models/device_component_templates.py:663
#, python-brace-format
msgid ""
"The number of positions cannot be less than the number of mapped front port "
"templates ({count})"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:703
#: netbox/dcim/models/device_component_templates.py:695
#: netbox/dcim/models/device_components.py:1287
msgid "position"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:706
#: netbox/dcim/models/device_component_templates.py:698
#: netbox/dcim/models/device_components.py:1290
msgid "Identifier to reference when renaming installed components"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:712
#: netbox/dcim/models/device_component_templates.py:704
msgid "module bay template"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:713
#: netbox/dcim/models/device_component_templates.py:705
msgid "module bay templates"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:745
#: netbox/dcim/models/device_component_templates.py:737
msgid "device bay template"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:746
#: netbox/dcim/models/device_component_templates.py:738
msgid "device bay templates"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:760
#: netbox/dcim/models/device_component_templates.py:752
#, python-brace-format
msgid ""
"Subdevice role of device type ({device_type}) must be set to \"parent\" to "
"allow device bays."
msgstr ""
#: netbox/dcim/models/device_component_templates.py:815
#: netbox/dcim/models/device_component_templates.py:807
#: netbox/dcim/models/device_components.py:1447
msgid "part ID"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:817
#: netbox/dcim/models/device_component_templates.py:809
#: netbox/dcim/models/device_components.py:1449
msgid "Manufacturer-assigned part identifier"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:834
#: netbox/dcim/models/device_component_templates.py:826
msgid "inventory item template"
msgstr ""
#: netbox/dcim/models/device_component_templates.py:835
#: netbox/dcim/models/device_component_templates.py:827
msgid "inventory item templates"
msgstr ""
@@ -6448,7 +6448,7 @@ msgid "module bays"
msgstr ""
#: netbox/dcim/models/device_components.py:1322
#: netbox/dcim/models/modules.py:276
#: netbox/dcim/models/modules.py:268
msgid "A module bay cannot belong to a module installed within it."
msgstr ""
@@ -6484,14 +6484,14 @@ msgid "inventory item roles"
msgstr ""
#: netbox/dcim/models/device_components.py:1453
#: netbox/dcim/models/devices.py:551 netbox/dcim/models/modules.py:235
#: netbox/dcim/models/devices.py:542 netbox/dcim/models/modules.py:227
#: netbox/dcim/models/racks.py:317
#: netbox/virtualization/models/virtualmachines.py:132
msgid "serial number"
msgstr ""
#: netbox/dcim/models/device_components.py:1461
#: netbox/dcim/models/devices.py:559 netbox/dcim/models/modules.py:242
#: netbox/dcim/models/devices.py:550 netbox/dcim/models/modules.py:234
#: netbox/dcim/models/racks.py:324
msgid "asset tag"
msgstr ""
@@ -6587,7 +6587,7 @@ msgid ""
"device type is neither a parent nor a child."
msgstr ""
#: netbox/dcim/models/devices.py:131 netbox/dcim/models/devices.py:604
#: netbox/dcim/models/devices.py:131 netbox/dcim/models/devices.py:595
#: netbox/dcim/models/modules.py:87 netbox/dcim/models/racks.py:328
msgid "airflow"
msgstr ""
@@ -6600,310 +6600,310 @@ msgstr ""
msgid "device types"
msgstr ""
#: netbox/dcim/models/devices.py:304
#: netbox/dcim/models/devices.py:295
msgid "U height must be in increments of 0.5 rack units."
msgstr ""
#: netbox/dcim/models/devices.py:321
#: netbox/dcim/models/devices.py:312
#, python-brace-format
msgid ""
"Device {device} in rack {rack} does not have sufficient space to accommodate "
"a height of {height}U"
msgstr ""
#: netbox/dcim/models/devices.py:336
#: netbox/dcim/models/devices.py:327
#, python-brace-format
msgid ""
"Unable to set 0U height: Found <a href=\"{url}\">{racked_instance_count} "
"instances</a> already mounted within racks."
msgstr ""
#: netbox/dcim/models/devices.py:345
#: netbox/dcim/models/devices.py:336
msgid ""
"Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
msgstr ""
#: netbox/dcim/models/devices.py:351
#: netbox/dcim/models/devices.py:342
msgid "Child device types must be 0U."
msgstr ""
#: netbox/dcim/models/devices.py:400
#: netbox/dcim/models/devices.py:391
msgid "Virtual machines may be assigned to this role"
msgstr ""
#: netbox/dcim/models/devices.py:426
#: netbox/dcim/models/devices.py:417
msgid "A top-level device role with this name already exists."
msgstr ""
#: netbox/dcim/models/devices.py:436
#: netbox/dcim/models/devices.py:427
msgid "A top-level device role with this slug already exists."
msgstr ""
#: netbox/dcim/models/devices.py:439
#: netbox/dcim/models/devices.py:430
msgid "device role"
msgstr ""
#: netbox/dcim/models/devices.py:440
#: netbox/dcim/models/devices.py:431
msgid "device roles"
msgstr ""
#: netbox/dcim/models/devices.py:454
#: netbox/dcim/models/devices.py:445
msgid "Optionally limit this platform to devices of a certain manufacturer"
msgstr ""
#: netbox/dcim/models/devices.py:471
#: netbox/dcim/models/devices.py:462
msgid "platform"
msgstr ""
#: netbox/dcim/models/devices.py:472
#: netbox/dcim/models/devices.py:463
msgid "platforms"
msgstr ""
#: netbox/dcim/models/devices.py:482
#: netbox/dcim/models/devices.py:473
msgid "Platform name must be unique."
msgstr ""
#: netbox/dcim/models/devices.py:492
#: netbox/dcim/models/devices.py:483
msgid "Platform slug must be unique."
msgstr ""
#: netbox/dcim/models/devices.py:525
#: netbox/dcim/models/devices.py:516
msgid "The function this device serves"
msgstr ""
#: netbox/dcim/models/devices.py:552
#: netbox/dcim/models/devices.py:543
msgid "Chassis serial number, assigned by the manufacturer"
msgstr ""
#: netbox/dcim/models/devices.py:560 netbox/dcim/models/modules.py:243
#: netbox/dcim/models/devices.py:551 netbox/dcim/models/modules.py:235
msgid "A unique tag used to identify this device"
msgstr ""
#: netbox/dcim/models/devices.py:587
#: netbox/dcim/models/devices.py:578
msgid "position (U)"
msgstr ""
#: netbox/dcim/models/devices.py:595
#: netbox/dcim/models/devices.py:586
msgid "rack face"
msgstr ""
#: netbox/dcim/models/devices.py:616 netbox/dcim/models/devices.py:1243
#: netbox/dcim/models/devices.py:607 netbox/dcim/models/devices.py:1234
#: netbox/virtualization/models/virtualmachines.py:101
msgid "primary IPv4"
msgstr ""
#: netbox/dcim/models/devices.py:624 netbox/dcim/models/devices.py:1251
#: netbox/dcim/models/devices.py:615 netbox/dcim/models/devices.py:1242
#: netbox/virtualization/models/virtualmachines.py:109
msgid "primary IPv6"
msgstr ""
#: netbox/dcim/models/devices.py:632
#: netbox/dcim/models/devices.py:623
msgid "out-of-band IP"
msgstr ""
#: netbox/dcim/models/devices.py:649
#: netbox/dcim/models/devices.py:640
msgid "VC position"
msgstr ""
#: netbox/dcim/models/devices.py:652
#: netbox/dcim/models/devices.py:643
msgid "Virtual chassis position"
msgstr ""
#: netbox/dcim/models/devices.py:655
#: netbox/dcim/models/devices.py:646
msgid "VC priority"
msgstr ""
#: netbox/dcim/models/devices.py:659
#: netbox/dcim/models/devices.py:650
msgid "Virtual chassis master election priority"
msgstr ""
#: netbox/dcim/models/devices.py:662 netbox/dcim/models/sites.py:217
#: netbox/dcim/models/devices.py:653 netbox/dcim/models/sites.py:217
msgid "latitude"
msgstr ""
#: netbox/dcim/models/devices.py:671 netbox/dcim/models/devices.py:683
#: netbox/dcim/models/devices.py:662 netbox/dcim/models/devices.py:674
#: netbox/dcim/models/sites.py:226 netbox/dcim/models/sites.py:238
msgid "GPS coordinate in decimal format (xx.yyyyyy)"
msgstr ""
#: netbox/dcim/models/devices.py:674 netbox/dcim/models/sites.py:229
#: netbox/dcim/models/devices.py:665 netbox/dcim/models/sites.py:229
msgid "longitude"
msgstr ""
#: netbox/dcim/models/devices.py:757
#: netbox/dcim/models/devices.py:748
msgid "Device name must be unique per site."
msgstr ""
#: netbox/dcim/models/devices.py:768
#: netbox/dcim/models/devices.py:759
msgid "device"
msgstr ""
#: netbox/dcim/models/devices.py:769
#: netbox/dcim/models/devices.py:760
msgid "devices"
msgstr ""
#: netbox/dcim/models/devices.py:788
#: netbox/dcim/models/devices.py:779
#, python-brace-format
msgid "Rack {rack} does not belong to site {site}."
msgstr ""
#: netbox/dcim/models/devices.py:793
#: netbox/dcim/models/devices.py:784
#, python-brace-format
msgid "Location {location} does not belong to site {site}."
msgstr ""
#: netbox/dcim/models/devices.py:799
#: netbox/dcim/models/devices.py:790
#, python-brace-format
msgid "Rack {rack} does not belong to location {location}."
msgstr ""
#: netbox/dcim/models/devices.py:806
#: netbox/dcim/models/devices.py:797
msgid "Cannot select a rack face without assigning a rack."
msgstr ""
#: netbox/dcim/models/devices.py:810
#: netbox/dcim/models/devices.py:801
msgid "Cannot select a rack position without assigning a rack."
msgstr ""
#: netbox/dcim/models/devices.py:816
#: netbox/dcim/models/devices.py:807
msgid "Position must be in increments of 0.5 rack units."
msgstr ""
#: netbox/dcim/models/devices.py:820
#: netbox/dcim/models/devices.py:811
msgid "Must specify rack face when defining rack position."
msgstr ""
#: netbox/dcim/models/devices.py:828
#: netbox/dcim/models/devices.py:819
#, python-brace-format
msgid "A 0U device type ({device_type}) cannot be assigned to a rack position."
msgstr ""
#: netbox/dcim/models/devices.py:839
#: netbox/dcim/models/devices.py:830
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:846
#: netbox/dcim/models/devices.py:837
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:860
#: netbox/dcim/models/devices.py:851
#, 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:875
#: netbox/dcim/models/devices.py:866
#, python-brace-format
msgid "{ip} is not an IPv4 address."
msgstr ""
#: netbox/dcim/models/devices.py:887 netbox/dcim/models/devices.py:905
#: netbox/dcim/models/devices.py:878 netbox/dcim/models/devices.py:896
#, python-brace-format
msgid "The specified IP address ({ip}) is not assigned to this device."
msgstr ""
#: netbox/dcim/models/devices.py:893
#: netbox/dcim/models/devices.py:884
#, python-brace-format
msgid "{ip} is not an IPv6 address."
msgstr ""
#: netbox/dcim/models/devices.py:923
#: netbox/dcim/models/devices.py:914
#, 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:934
#: netbox/dcim/models/devices.py:925
#, python-brace-format
msgid "The assigned cluster belongs to a different site ({site})"
msgstr ""
#: netbox/dcim/models/devices.py:941
#: netbox/dcim/models/devices.py:932
#, python-brace-format
msgid "The assigned cluster belongs to a different location ({location})"
msgstr ""
#: netbox/dcim/models/devices.py:949
#: netbox/dcim/models/devices.py:940
msgid "A device assigned to a virtual chassis must have its position defined."
msgstr ""
#: netbox/dcim/models/devices.py:955
#: netbox/dcim/models/devices.py:946
#, 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:1158
#: netbox/dcim/models/devices.py:1149
msgid "domain"
msgstr ""
#: netbox/dcim/models/devices.py:1171 netbox/dcim/models/devices.py:1172
#: netbox/dcim/models/devices.py:1162 netbox/dcim/models/devices.py:1163
msgid "virtual chassis"
msgstr ""
#: netbox/dcim/models/devices.py:1184
#: netbox/dcim/models/devices.py:1175
#, python-brace-format
msgid "The selected master ({master}) is not assigned to this virtual chassis."
msgstr ""
#: netbox/dcim/models/devices.py:1199
#: netbox/dcim/models/devices.py:1190
#, 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:1232 netbox/vpn/models/l2vpn.py:42
#: netbox/dcim/models/devices.py:1223 netbox/vpn/models/l2vpn.py:42
msgid "identifier"
msgstr ""
#: netbox/dcim/models/devices.py:1233
#: netbox/dcim/models/devices.py:1224
msgid "Numeric identifier unique to the parent device"
msgstr ""
#: netbox/dcim/models/devices.py:1261 netbox/extras/models/customfields.py:253
#: netbox/dcim/models/devices.py:1252 netbox/extras/models/customfields.py:253
#: netbox/extras/models/models.py:118 netbox/extras/models/models.py:813
#: netbox/netbox/models/__init__.py:134 netbox/netbox/models/__init__.py:173
#: netbox/netbox/models/__init__.py:223
msgid "comments"
msgstr ""
#: netbox/dcim/models/devices.py:1277
#: netbox/dcim/models/devices.py:1268
msgid "virtual device context"
msgstr ""
#: netbox/dcim/models/devices.py:1278
#: netbox/dcim/models/devices.py:1269
msgid "virtual device contexts"
msgstr ""
#: netbox/dcim/models/devices.py:1306
#: netbox/dcim/models/devices.py:1297
#, python-brace-format
msgid "{ip} is not an IPv{family} address."
msgstr ""
#: netbox/dcim/models/devices.py:1312
#: netbox/dcim/models/devices.py:1303
msgid "Primary IP address must belong to an interface on the assigned device."
msgstr ""
#: netbox/dcim/models/devices.py:1346
#: netbox/dcim/models/devices.py:1337
msgid "MAC addresses"
msgstr ""
#: netbox/dcim/models/devices.py:1378
#: netbox/dcim/models/devices.py:1369
msgid ""
"Cannot unassign MAC Address while it is designated as the primary MAC for an "
"object"
msgstr ""
#: netbox/dcim/models/devices.py:1382
#: netbox/dcim/models/devices.py:1373
msgid ""
"Cannot reassign MAC Address while it is designated as the primary MAC for an "
"object"
@@ -6959,15 +6959,15 @@ msgstr ""
msgid "Invalid schema: {error}"
msgstr ""
#: netbox/dcim/models/modules.py:250
#: netbox/dcim/models/modules.py:242
msgid "module"
msgstr ""
#: netbox/dcim/models/modules.py:251
#: netbox/dcim/models/modules.py:243
msgid "modules"
msgstr ""
#: netbox/dcim/models/modules.py:264
#: netbox/dcim/models/modules.py:256
#, python-brace-format
msgid ""
"Module must be installed within a module bay belonging to the assigned "
@@ -8005,12 +8005,7 @@ msgstr ""
msgid "Removed {device} from virtual chassis {chassis}"
msgstr ""
#: netbox/extras/api/customfields.py:94
#, python-brace-format
msgid "Custom field '{name}' does not exist for this object type."
msgstr ""
#: netbox/extras/api/customfields.py:110
#: netbox/extras/api/customfields.py:100
#, python-brace-format
msgid "Unknown related object(s): {name}"
msgstr ""