Compare commits

..

3 Commits

Author SHA1 Message Date
github-actions
4ca688de57 Update source translation strings 2026-04-10 05:40:14 +00:00
bctiemann
ed7ebd9d98 Merge pull request #21863 from netbox-community/21801-duplicate-filename-allowed-when-upload-files-using-s3
Fixes #21801: Ensure unique Image Attachment filenames when using S3 storage
2026-04-09 13:47:54 -04:00
Martin Hauser
e864dc3ae0 fix(extras): Ensure unique Image Attachment names on S3
Make image attachment filename generation use Django's base collision
handling so overwrite-style storage backends behave like local file
storage.

This preserves the original filename for the first upload, adds a
suffix only on collision, and avoids duplicate image paths in object
change records.

Add regression tests for path generation and collision handling.

Fixes #21801
2026-04-08 22:16:36 +02:00
6 changed files with 329 additions and 229 deletions

View File

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

View File

@@ -3603,21 +3603,6 @@ class CableTestCase(
cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6) cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6)
cable3.save() 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') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
@@ -3655,14 +3640,7 @@ class CableTestCase(
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3", "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 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", "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 = ( cls.csv_update_data = (

View File

@@ -1,10 +1,15 @@
import io
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType 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.core.files.uploadedfile import SimpleUploadedFile
from django.forms import ValidationError from django.forms import ValidationError
from django.test import TestCase, tag from django.test import TestCase, tag
from PIL import Image
from core.models import AutoSyncRecord, DataSource, ObjectType from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
@@ -14,10 +19,50 @@ from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine 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): class ImageAttachmentTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack') 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'' cls.image_content = b''
def _stub_image_attachment(self, object_id, image_filename, name=None): def _stub_image_attachment(self, object_id, image_filename, name=None):
@@ -41,6 +86,15 @@ class ImageAttachmentTests(TestCase):
) )
return ia 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): def test_filename_strips_expected_prefix(self):
""" """
Tests that the filename of the image attachment is stripped of the expected Tests that the filename of the image attachment is stripped of the expected
@@ -89,6 +143,37 @@ class ImageAttachmentTests(TestCase):
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='') ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
self.assertEqual('file.png', str(ia)) 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): class TagTest(TestCase):

View File

@@ -1,10 +1,12 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.files.storage import Storage
from django.test import TestCase from django.test import TestCase
from extras.models import ExportTemplate from extras.models import ExportTemplate, ImageAttachment
from extras.utils import filename_from_model, image_upload from extras.utils import _build_image_attachment_path, filename_from_model, image_upload
from tenancy.models import ContactGroup, TenantGroup from tenancy.models import ContactGroup, TenantGroup
from wireless.models import WirelessLANGroup from wireless.models import WirelessLANGroup
@@ -22,6 +24,25 @@ class FilenameFromModelTests(TestCase):
self.assertEqual(filename_from_model(model), expected) 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): class ImageUploadTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -31,16 +52,18 @@ class ImageUploadTests(TestCase):
def _stub_instance(self, object_id=12, name=None): def _stub_instance(self, object_id=12, name=None):
""" """
Creates a minimal stub for use with the `image_upload()` function. Creates a minimal stub for use with image attachment path generation.
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) 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): def _second_segment(self, path: str):
""" """
Extracts and returns the portion of the input string after the Extracts and returns the portion of the input string after the
@@ -53,7 +76,7 @@ class ImageUploadTests(TestCase):
Tests handling of a Windows file path with a fake directory and extension. Tests handling of a Windows file path with a fake directory and extension.
""" """
inst = self._stub_instance(name=None) inst = self._stub_instance(name=None)
path = image_upload(inst, r'C:\fake_path\MyPhoto.JPG') path = _build_image_attachment_path(inst, r'C:\fake_path\MyPhoto.JPG')
# Base directory and single-level path # Base directory and single-level path
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(path.startswith('image-attachments/rack_12_')) self.assertTrue(path.startswith('image-attachments/rack_12_'))
@@ -67,7 +90,7 @@ class ImageUploadTests(TestCase):
create subdirectories. create subdirectories.
""" """
inst = self._stub_instance(name='5/31/23') inst = self._stub_instance(name='5/31/23')
path = image_upload(inst, 'image.png') path = _build_image_attachment_path(inst, 'image.png')
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_')) self.assertTrue(seg2.startswith('rack_12_'))
self.assertNotIn('/', seg2) self.assertNotIn('/', seg2)
@@ -80,7 +103,7 @@ class ImageUploadTests(TestCase):
into a single directory name without creating subdirectories. into a single directory name without creating subdirectories.
""" """
inst = self._stub_instance(name=r'5\31\23') inst = self._stub_instance(name=r'5\31\23')
path = image_upload(inst, 'image_name.png') path = _build_image_attachment_path(inst, 'image_name.png')
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_')) self.assertTrue(seg2.startswith('rack_12_'))
@@ -93,7 +116,7 @@ class ImageUploadTests(TestCase):
Tests the output path format generated by the `image_upload` function. Tests the output path format generated by the `image_upload` function.
""" """
inst = self._stub_instance(object_id=99, name='label') inst = self._stub_instance(object_id=99, name='label')
path = image_upload(inst, 'a.webp') path = _build_image_attachment_path(inst, 'a.webp')
# The second segment must begin with "rack_99_" # The second segment must begin with "rack_99_"
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_99_')) self.assertTrue(seg2.startswith('rack_99_'))
@@ -105,7 +128,7 @@ class ImageUploadTests(TestCase):
is omitted. is omitted.
""" """
inst = self._stub_instance(name='test') inst = self._stub_instance(name='test')
path = image_upload(inst, 'document.txt') path = _build_image_attachment_path(inst, 'document.txt')
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_test')) self.assertTrue(seg2.startswith('rack_12_test'))
@@ -121,7 +144,7 @@ class ImageUploadTests(TestCase):
# Suppose the instance name has surrounding whitespace and # Suppose the instance name has surrounding whitespace and
# extra slashes. # extra slashes.
inst = self._stub_instance(name=' my/complex\\name ') inst = self._stub_instance(name=' my/complex\\name ')
path = image_upload(inst, 'irrelevant.png') path = _build_image_attachment_path(inst, 'irrelevant.png')
# The output should be flattened and sanitized. # The output should be flattened and sanitized.
# We expect the name to be transformed into a valid filename without # We expect the name to be transformed into a valid filename without
@@ -141,7 +164,7 @@ class ImageUploadTests(TestCase):
for name in ['2025/09/12', r'2025\09\12']: for name in ['2025/09/12', r'2025\09\12']:
with self.subTest(name=name): with self.subTest(name=name):
inst = self._stub_instance(name=name) inst = self._stub_instance(name=name)
path = image_upload(inst, 'x.jpeg') path = _build_image_attachment_path(inst, 'x.jpeg')
seg2 = self._second_segment(path) seg2 = self._second_segment(path)
self.assertTrue(seg2.startswith('rack_12_')) self.assertTrue(seg2.startswith('rack_12_'))
self.assertNotIn('/', seg2) self.assertNotIn('/', seg2)
@@ -154,7 +177,49 @@ class ImageUploadTests(TestCase):
SuspiciousFileOperation, the fallback default is used. SuspiciousFileOperation, the fallback default is used.
""" """
inst = self._stub_instance(name=' ') inst = self._stub_instance(name=' ')
path = image_upload(inst, 'sample.png') path = _build_image_attachment_path(inst, 'sample.png')
# Expect the fallback name 'unnamed' to be used. # Expect the fallback name 'unnamed' to be used.
self.assertIn('unnamed', path) self.assertIn('unnamed', path)
self.assertTrue(path.startswith('image-attachments/rack_12_')) 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 pathlib import Path
from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
from django.core.files.storage import default_storage from django.core.files.storage import Storage, default_storage
from django.core.files.utils import validate_file_name from django.core.files.utils import validate_file_name
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@@ -67,15 +67,13 @@ def is_taggable(obj):
return False return False
def image_upload(instance, filename): def _build_image_attachment_path(instance, filename, *, storage=default_storage):
""" """
Return a path for uploading image attachments. Build a deterministic relative path for an image attachment.
- Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg) - Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg)
- Uses the instance.name if provided (sanitized to a *basename*, no ext) - Uses the instance.name if provided (sanitized to a *basename*, no ext)
- Prefixes with a machine-friendly identifier - Prefixes with a machine-friendly identifier
Note: Relies on Django's default_storage utility.
""" """
upload_dir = 'image-attachments' upload_dir = 'image-attachments'
default_filename = 'unnamed' default_filename = 'unnamed'
@@ -92,22 +90,38 @@ def image_upload(instance, filename):
# Rely on Django's get_valid_filename to perform sanitization. # Rely on Django's get_valid_filename to perform sanitization.
stem = (instance.name or file_path.stem).strip() stem = (instance.name or file_path.stem).strip()
try: try:
safe_stem = default_storage.get_valid_name(stem) safe_stem = storage.get_valid_name(stem)
except SuspiciousFileOperation: except SuspiciousFileOperation:
safe_stem = default_filename safe_stem = default_filename
# Append the uploaded extension only if it's an allowed image type # 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 # Create a machine-friendly prefix from the instance
prefix = f"{instance.object_type.model}_{instance.object_id}" prefix = f'{instance.object_type.model}_{instance.object_id}'
name_with_path = f"{upload_dir}/{prefix}_{final_name}" name_with_path = f'{upload_dir}/{prefix}_{final_name}'
# Validate the generated relative path (blocks absolute/traversal) # Validate the generated relative path (blocks absolute/traversal)
validate_file_name(name_with_path, allow_relative_path=True) validate_file_name(name_with_path, allow_relative_path=True)
return name_with_path 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): def is_script(obj):
""" """
Returns True if the object is a Script or Report. Returns True if the object is a Script or Report.

View File

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