@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
@@ -728,7 +728,9 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return os.path.basename(self.image.name).split('_', 2)[2]
|
base_name = Path(self.image.name).name
|
||||||
|
prefix = f"{self.object_type.model}_{self.object_id}_"
|
||||||
|
return base_name.removeprefix(prefix)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def html_tag(self):
|
def html_tag(self):
|
||||||
|
@@ -1,17 +1,95 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.test import tag, TestCase
|
from django.test import tag, TestCase
|
||||||
|
|
||||||
from core.models import DataSource, ObjectType
|
from core.models import 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
|
||||||
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, Tag
|
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
|
|
||||||
|
|
||||||
|
class ImageAttachmentTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
|
||||||
|
cls.image_content = b''
|
||||||
|
|
||||||
|
def _stub_image_attachment(self, object_id, image_filename, name=None):
|
||||||
|
"""
|
||||||
|
Creates an instance of ImageAttachment with the provided object_id and image_name.
|
||||||
|
|
||||||
|
This method prepares a stubbed image attachment to test functionalities that
|
||||||
|
require an ImageAttachment object.
|
||||||
|
The function initializes the attachment with a specified file name and
|
||||||
|
pre-defined image content.
|
||||||
|
"""
|
||||||
|
ia = ImageAttachment(
|
||||||
|
object_type=self.ct_rack,
|
||||||
|
object_id=object_id,
|
||||||
|
name=name,
|
||||||
|
image=SimpleUploadedFile(
|
||||||
|
name=image_filename,
|
||||||
|
content=self.image_content,
|
||||||
|
content_type='image/jpeg',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ia
|
||||||
|
|
||||||
|
def test_filename_strips_expected_prefix(self):
|
||||||
|
"""
|
||||||
|
Tests that the filename of the image attachment is stripped of the expected
|
||||||
|
prefix.
|
||||||
|
"""
|
||||||
|
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_My_File.png')
|
||||||
|
self.assertEqual(ia.filename, 'My_File.png')
|
||||||
|
|
||||||
|
def test_filename_legacy_nested_path_returns_basename(self):
|
||||||
|
"""
|
||||||
|
Tests if the filename of a legacy-nested path correctly returns only the basename.
|
||||||
|
"""
|
||||||
|
# e.g. "image-attachments/rack_12_5/31/23.jpg" -> "23.jpg"
|
||||||
|
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_5/31/23.jpg')
|
||||||
|
self.assertEqual(ia.filename, '23.jpg')
|
||||||
|
|
||||||
|
def test_filename_no_prefix_returns_basename(self):
|
||||||
|
"""
|
||||||
|
Tests that the filename property correctly returns the basename for an image
|
||||||
|
attachment that has no leading prefix in its path.
|
||||||
|
"""
|
||||||
|
ia = self._stub_image_attachment(42, 'image-attachments/just_name.webp')
|
||||||
|
self.assertEqual(ia.filename, 'just_name.webp')
|
||||||
|
|
||||||
|
def test_mismatched_prefix_is_not_stripped(self):
|
||||||
|
"""
|
||||||
|
Tests that a mismatched prefix in the filename is not stripped.
|
||||||
|
"""
|
||||||
|
# Prefix does not match object_id -> leave as-is (basename only)
|
||||||
|
ia = self._stub_image_attachment(12, 'image-attachments/rack_13_other.png')
|
||||||
|
self.assertEqual('rack_13_other.png', ia.filename)
|
||||||
|
|
||||||
|
def test_str_uses_name_when_present(self):
|
||||||
|
"""
|
||||||
|
Tests that the `str` representation of the object uses the
|
||||||
|
`name` attribute when provided.
|
||||||
|
"""
|
||||||
|
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='Human title')
|
||||||
|
self.assertEqual('Human title', str(ia))
|
||||||
|
|
||||||
|
def test_str_falls_back_to_filename(self):
|
||||||
|
"""
|
||||||
|
Tests that the `str` representation of the object falls back to
|
||||||
|
the filename if the name attribute is not set.
|
||||||
|
"""
|
||||||
|
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
|
||||||
|
self.assertEqual('file.png', str(ia))
|
||||||
|
|
||||||
|
|
||||||
class TagTest(TestCase):
|
class TagTest(TestCase):
|
||||||
|
|
||||||
def test_default_ordering_weight_then_name_is_set(self):
|
def test_default_ordering_weight_then_name_is_set(self):
|
||||||
@@ -445,7 +523,7 @@ class ConfigContextTest(TestCase):
|
|||||||
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
|
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
|
||||||
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
|
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
|
||||||
|
|
||||||
# Check that their individually-rendered config contexts are identical
|
# Check that their individually rendered config contexts are identical
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
vm1.get_config_context(),
|
vm1.get_config_context(),
|
||||||
vm2.get_config_context()
|
vm2.get_config_context()
|
||||||
@@ -462,7 +540,7 @@ class ConfigContextTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||||
This is combated by appending distinct() to the config context querysets. This test creates a config
|
This is combated by appending distinct() to the config context querysets. This test creates a config
|
||||||
context assigned to two tags and ensures objects related by those same two tags result in only a single
|
context assigned to two tags and ensures objects related to those same two tags result in only a single
|
||||||
config context record being returned.
|
config context record being returned.
|
||||||
|
|
||||||
See https://github.com/netbox-community/netbox/issues/5314
|
See https://github.com/netbox-community/netbox/issues/5314
|
||||||
@@ -495,14 +573,14 @@ class ConfigContextTest(TestCase):
|
|||||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
||||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||||
|
|
||||||
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
|
def test_multiple_tags_return_distinct_objects_with_separate_config_contexts(self):
|
||||||
"""
|
"""
|
||||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||||
This is combatted by by appending distinct() to the config context querysets. This test creates a config
|
This is combated by appending distinct() to the config context querysets. This test creates a config
|
||||||
context assigned to two tags and ensures objects related by those same two tags result in only a single
|
context assigned to two tags and ensures objects related to those same two tags result in only a single
|
||||||
config context record being returned.
|
config context record being returned.
|
||||||
|
|
||||||
This test case is seperate from the above in that it deals with multiple config context objects in play.
|
This test case is separate from the above in that it deals with multiple config context objects in play.
|
||||||
|
|
||||||
See https://github.com/netbox-community/netbox/issues/5387
|
See https://github.com/netbox-community/netbox/issues/5387
|
||||||
"""
|
"""
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from extras.models import ExportTemplate
|
from extras.models import ExportTemplate
|
||||||
from extras.utils import filename_from_model
|
from extras.utils import 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
|
||||||
|
|
||||||
@@ -17,3 +20,141 @@ class FilenameFromModelTests(TestCase):
|
|||||||
|
|
||||||
for model, expected in cases:
|
for model, expected in cases:
|
||||||
self.assertEqual(filename_from_model(model), expected)
|
self.assertEqual(filename_from_model(model), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageUploadTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# We only need a ContentType with model="rack" for the prefix;
|
||||||
|
# this doesn't require creating a Rack object.
|
||||||
|
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
|
||||||
|
|
||||||
|
def _stub_instance(self, object_id=12, name=None):
|
||||||
|
"""
|
||||||
|
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 _second_segment(self, path: str):
|
||||||
|
"""
|
||||||
|
Extracts and returns the portion of the input string after the
|
||||||
|
first '/' character.
|
||||||
|
"""
|
||||||
|
return path.split('/', 1)[1]
|
||||||
|
|
||||||
|
def test_windows_fake_path_and_extension_lowercased(self):
|
||||||
|
"""
|
||||||
|
Tests handling of a Windows file path with a fake directory and extension.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(name=None)
|
||||||
|
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_'))
|
||||||
|
self.assertNotIn('/', seg2, 'should not create nested directories')
|
||||||
|
# Extension from the uploaded file, lowercased
|
||||||
|
self.assertTrue(seg2.endswith('.jpg'))
|
||||||
|
|
||||||
|
def test_name_with_slashes_is_flattened_no_subdirectories(self):
|
||||||
|
"""
|
||||||
|
Tests that a name with slashes is flattened and does not
|
||||||
|
create subdirectories.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(name='5/31/23')
|
||||||
|
path = image_upload(inst, 'image.png')
|
||||||
|
seg2 = self._second_segment(path)
|
||||||
|
self.assertTrue(seg2.startswith('rack_12_'))
|
||||||
|
self.assertNotIn('/', seg2)
|
||||||
|
self.assertNotIn('\\', seg2)
|
||||||
|
self.assertTrue(seg2.endswith('.png'))
|
||||||
|
|
||||||
|
def test_name_with_backslashes_is_flattened_no_subdirectories(self):
|
||||||
|
"""
|
||||||
|
Tests that a name including backslashes is correctly flattened
|
||||||
|
into a single directory name without creating subdirectories.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(name=r'5\31\23')
|
||||||
|
path = image_upload(inst, 'image_name.png')
|
||||||
|
|
||||||
|
seg2 = self._second_segment(path)
|
||||||
|
self.assertTrue(seg2.startswith('rack_12_'))
|
||||||
|
self.assertNotIn('/', seg2)
|
||||||
|
self.assertNotIn('\\', seg2)
|
||||||
|
self.assertTrue(seg2.endswith('.png'))
|
||||||
|
|
||||||
|
def test_prefix_format_is_as_expected(self):
|
||||||
|
"""
|
||||||
|
Tests the output path format generated by the `image_upload` function.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(object_id=99, name='label')
|
||||||
|
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_'))
|
||||||
|
self.assertTrue(seg2.endswith('.webp'))
|
||||||
|
|
||||||
|
def test_unsupported_file_extension(self):
|
||||||
|
"""
|
||||||
|
Test that when the file extension is not allowed, the extension
|
||||||
|
is omitted.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(name='test')
|
||||||
|
path = image_upload(inst, 'document.txt')
|
||||||
|
|
||||||
|
seg2 = self._second_segment(path)
|
||||||
|
self.assertTrue(seg2.startswith('rack_12_test'))
|
||||||
|
self.assertFalse(seg2.endswith('.txt'))
|
||||||
|
# When not allowed, no extension should be appended
|
||||||
|
self.assertNotRegex(seg2, r'\.txt$')
|
||||||
|
|
||||||
|
def test_instance_name_with_whitespace_and_special_chars(self):
|
||||||
|
"""
|
||||||
|
Test that an instance name with leading/trailing whitespace and
|
||||||
|
special characters is sanitized properly.
|
||||||
|
"""
|
||||||
|
# Suppose the instance name has surrounding whitespace and
|
||||||
|
# extra slashes.
|
||||||
|
inst = self._stub_instance(name=' my/complex\\name ')
|
||||||
|
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
|
||||||
|
# path separators.
|
||||||
|
seg2 = self._second_segment(path)
|
||||||
|
self.assertNotIn(' ', seg2)
|
||||||
|
self.assertNotIn('/', seg2)
|
||||||
|
self.assertNotIn('\\', seg2)
|
||||||
|
self.assertTrue(seg2.endswith('.png'))
|
||||||
|
|
||||||
|
def test_separator_variants_with_subTest(self):
|
||||||
|
"""
|
||||||
|
Tests that both forward slash and backslash in file paths are
|
||||||
|
handled consistently by the `image_upload` function and
|
||||||
|
processed into a sanitized uniform format.
|
||||||
|
"""
|
||||||
|
for name in ['2025/09/12', r'2025\09\12']:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
inst = self._stub_instance(name=name)
|
||||||
|
path = image_upload(inst, 'x.jpeg')
|
||||||
|
seg2 = self._second_segment(path)
|
||||||
|
self.assertTrue(seg2.startswith('rack_12_'))
|
||||||
|
self.assertNotIn('/', seg2)
|
||||||
|
self.assertNotIn('\\', seg2)
|
||||||
|
self.assertTrue(seg2.endswith('.jpeg') or seg2.endswith('.jpg'))
|
||||||
|
|
||||||
|
def test_fallback_on_suspicious_file_operation(self):
|
||||||
|
"""
|
||||||
|
Test that when default_storage.get_valid_name() raises a
|
||||||
|
SuspiciousFileOperation, the fallback default is used.
|
||||||
|
"""
|
||||||
|
inst = self._stub_instance(name=' ')
|
||||||
|
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_'))
|
||||||
|
@@ -1,15 +1,20 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
|
||||||
|
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 import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from taggit.managers import _TaggableManager
|
from taggit.managers import _TaggableManager
|
||||||
|
|
||||||
from netbox.context import current_request
|
from netbox.context import current_request
|
||||||
|
|
||||||
from .validators import CustomValidator
|
from .validators import CustomValidator
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'SharedObjectViewMixin',
|
'SharedObjectViewMixin',
|
||||||
|
'filename_from_model',
|
||||||
'image_upload',
|
'image_upload',
|
||||||
'is_report',
|
'is_report',
|
||||||
'is_script',
|
'is_script',
|
||||||
@@ -35,13 +40,13 @@ class SharedObjectViewMixin:
|
|||||||
|
|
||||||
|
|
||||||
def filename_from_model(model: models.Model) -> str:
|
def filename_from_model(model: models.Model) -> str:
|
||||||
"""Standardises how we generate filenames from model class for exports"""
|
"""Standardizes how we generate filenames from model class for exports"""
|
||||||
base = model._meta.verbose_name_plural.lower().replace(' ', '_')
|
base = model._meta.verbose_name_plural.lower().replace(' ', '_')
|
||||||
return f'netbox_{base}'
|
return f'netbox_{base}'
|
||||||
|
|
||||||
|
|
||||||
def filename_from_object(context: dict) -> str:
|
def filename_from_object(context: dict) -> str:
|
||||||
"""Standardises how we generate filenames from model class for exports"""
|
"""Standardizes how we generate filenames from model class for exports"""
|
||||||
if 'device' in context:
|
if 'device' in context:
|
||||||
base = f"{context['device'].name or 'config'}"
|
base = f"{context['device'].name or 'config'}"
|
||||||
elif 'virtualmachine' in context:
|
elif 'virtualmachine' in context:
|
||||||
@@ -64,17 +69,42 @@ def is_taggable(obj):
|
|||||||
def image_upload(instance, filename):
|
def image_upload(instance, filename):
|
||||||
"""
|
"""
|
||||||
Return a path for uploading image attachments.
|
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.
|
||||||
"""
|
"""
|
||||||
path = 'image-attachments/'
|
upload_dir = 'image-attachments'
|
||||||
|
default_filename = 'unnamed'
|
||||||
|
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
||||||
|
|
||||||
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
|
# Normalize Windows paths and create a Path object.
|
||||||
extension = filename.rsplit('.')[-1].lower()
|
normalized_filename = str(filename).replace('\\', '/')
|
||||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']:
|
file_path = Path(normalized_filename)
|
||||||
filename = '.'.join([instance.name, extension])
|
|
||||||
elif instance.name:
|
|
||||||
filename = instance.name
|
|
||||||
|
|
||||||
return '{}{}_{}_{}'.format(path, instance.object_type.name, instance.object_id, filename)
|
# Extract the extension from the uploaded file.
|
||||||
|
ext = file_path.suffix.lower().lstrip('.')
|
||||||
|
|
||||||
|
# Use the instance-provided name if available; otherwise use the file stem.
|
||||||
|
# Rely on Django's get_valid_filename to perform sanitization.
|
||||||
|
stem = (instance.name or file_path.stem).strip()
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
# Validate the generated relative path (blocks absolute/traversal)
|
||||||
|
validate_file_name(name_with_path, allow_relative_path=True)
|
||||||
|
return name_with_path
|
||||||
|
|
||||||
|
|
||||||
def is_script(obj):
|
def is_script(obj):
|
||||||
@@ -107,7 +137,7 @@ def run_validators(instance, validators):
|
|||||||
request = current_request.get()
|
request = current_request.get()
|
||||||
for validator in validators:
|
for validator in validators:
|
||||||
|
|
||||||
# Loading a validator class by dotted path
|
# Loading a validator class by a dotted path
|
||||||
if type(validator) is str:
|
if type(validator) is str:
|
||||||
module, cls = validator.rsplit('.', 1)
|
module, cls = validator.rsplit('.', 1)
|
||||||
validator = getattr(importlib.import_module(module), cls)()
|
validator = getattr(importlib.import_module(module), cls)()
|
||||||
|
Reference in New Issue
Block a user