Merge branch 'main' into feature
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

This commit is contained in:
Jeremy Stretch
2025-09-16 12:00:48 -04:00
110 changed files with 299711 additions and 37418 deletions
+2 -2
View File
@@ -76,11 +76,11 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
validation_minimum = forms.IntegerField(
validation_minimum = forms.DecimalField(
label=_('Minimum value'),
required=False,
)
validation_maximum = forms.IntegerField(
validation_maximum = forms.DecimalField(
label=_('Maximum value'),
required=False,
)
+2 -2
View File
@@ -103,11 +103,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
validation_minimum = forms.IntegerField(
validation_minimum = forms.DecimalField(
label=_('Minimum value'),
required=False
)
validation_maximum = forms.IntegerField(
validation_maximum = forms.DecimalField(
label=_('Maximum value'),
required=False
)
+6 -6
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
)
from tenancy.graphql.filters import TenantFilter, TenantGroupFilter
from netbox.graphql.enums import ColorEnum
from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
from users.graphql.filters import GroupFilter, UserFilter
from virtualization.graphql.filters import ClusterFilter, ClusterGroupFilter, ClusterTypeFilter
from .enums import *
@@ -43,12 +43,12 @@ __all__ = (
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
description: FilterLookup[str] = strawberry_django.filter_field()
is_active: FilterLookup[bool] = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -151,10 +151,10 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
validation_minimum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
validation_minimum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
validation_maximum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
validation_regex: FilterLookup[str] | None = strawberry_django.filter_field()
+1 -1
View File
@@ -106,7 +106,7 @@ class ScriptJob(JobRunner):
# Add the current request as a property of the script
script.request = request
self.logger.debug(f"Request ID: {request.id}")
self.logger.debug(f"Request ID: {request.id if request else None}")
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc.
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0132_configcontextprofile'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='validation_maximum',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=16, null=True),
),
migrations.AlterField(
model_name='customfield',
name='validation_minimum',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=16, null=True),
),
]
+8 -4
View File
@@ -174,13 +174,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
verbose_name=_('display weight'),
help_text=_('Fields with higher weights appear lower in a form.')
)
validation_minimum = models.BigIntegerField(
validation_minimum = models.DecimalField(
max_digits=16,
decimal_places=4,
blank=True,
null=True,
verbose_name=_('minimum value'),
help_text=_('Minimum allowed value (for numeric fields)')
)
validation_maximum = models.BigIntegerField(
validation_maximum = models.DecimalField(
max_digits=16,
decimal_places=4,
blank=True,
null=True,
verbose_name=_('maximum value'),
@@ -471,7 +475,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field = forms.DecimalField(
required=required,
initial=initial,
max_digits=12,
max_digits=16,
decimal_places=4,
min_value=self.validation_minimum,
max_value=self.validation_maximum
@@ -534,7 +538,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
field = JSONField(required=required, initial=json.dumps(initial) if initial is not None else None)
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
+4 -2
View File
@@ -1,6 +1,6 @@
import json
import os
import urllib.parse
from pathlib import Path
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -728,7 +728,9 @@ class ImageAttachment(ChangeLoggedModel):
@property
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
def html_tag(self):
+14 -5
View File
@@ -22,9 +22,10 @@ class ConfigContextQuerySet(RestrictedQuerySet):
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
"""
# Device type and location assignment is relevant only for Devices
# Device type and location assignment are relevant only for Devices
device_type = getattr(obj, 'device_type', None)
location = getattr(obj, 'location', None)
locations = location.get_ancestors(include_self=True) if location else []
# Get assigned cluster, group, and type (if any)
cluster = getattr(obj, 'cluster', None)
@@ -49,7 +50,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
Q(regions__in=regions) | Q(regions=None),
Q(site_groups__in=sitegroups) | Q(site_groups=None),
Q(sites=obj.site) | Q(sites=None),
Q(locations=location) | Q(locations=None),
Q(locations__in=locations) | Q(locations=None),
Q(device_types=device_type) | Q(device_types=None),
Q(roles__in=device_roles) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
@@ -92,7 +93,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
_data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
).values("_data").order_by()
)
).distinct()
)
def _get_config_context_filters(self):
# Construct the set of Q objects for the specific object types
@@ -116,7 +117,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
).values_list(
'tag_id',
flat=True
)
).distinct()
)
) | Q(tags=None),
is_active=True,
@@ -124,7 +125,15 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
# Apply Location & DeviceType filters only for VirtualMachines
if self.model._meta.model_name == 'device':
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
base_query.add(
(Q(
locations__tree_id=OuterRef('location__tree_id'),
locations__level__lte=OuterRef('location__level'),
locations__lft__lte=OuterRef('location__lft'),
locations__rght__gte=OuterRef('location__rght'),
) | Q(locations=None)),
Q.AND
)
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
elif self.model._meta.model_name == 'virtualmachine':
base_query.add(Q(locations=None), Q.AND)
+3 -2
View File
@@ -725,8 +725,9 @@ class ScriptResultsTable(BaseTable):
index = tables.Column(
verbose_name=_('Line')
)
time = tables.Column(
verbose_name=_('Time')
time = columns.DateTimeColumn(
verbose_name=_('Time'),
timespec='seconds'
)
status = tables.TemplateColumn(
template_code="""{% load log_levels %}{% log_level record.status %}""",
+56
View File
@@ -1,7 +1,9 @@
import datetime
import json
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.test import tag
from django.urls import reverse
from rest_framework import status
@@ -269,6 +271,60 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
@tag('regression')
def test_json_field_falsy_defaults(self):
"""Test that falsy JSON default values are properly handled"""
falsy_test_cases = [
({}, 'empty_dict'),
([], 'empty_array'),
(0, 'zero'),
(False, 'false_bool'),
("", 'empty_string'),
]
for default, suffix in falsy_test_cases:
with self.subTest(default=default, suffix=suffix):
cf = CustomField.objects.create(
name=f'json_falsy_{suffix}',
type=CustomFieldTypeChoices.TYPE_JSON,
default=default,
required=False
)
cf.object_types.set([self.object_type])
instance = Site.objects.create(name=f'Test Site {suffix}', slug=f'test-site-{suffix}')
self.assertIsNotNone(instance.custom_field_data)
self.assertIn(cf.name, instance.custom_field_data)
instance.refresh_from_db()
stored = instance.custom_field_data[cf.name]
self.assertEqual(stored, default)
@tag('regression')
def test_json_field_falsy_to_form_field(self):
"""Test form field generation preserves falsy defaults"""
falsy_test_cases = (
({}, json.dumps({}), 'empty_dict'),
([], json.dumps([]), 'empty_array'),
(0, json.dumps(0), 'zero'),
(False, json.dumps(False), 'false_bool'),
("", '""', 'empty_string'),
)
for default, expected, suffix in falsy_test_cases:
with self.subTest(default=default, expected=expected, suffix=suffix):
cf = CustomField.objects.create(
name=f'json_falsy_{suffix}',
type=CustomFieldTypeChoices.TYPE_JSON,
default=default,
required=False
)
cf.object_types.set([self.object_type])
form_field = cf.to_form_field(set_initial=True)
self.assertEqual(form_field.initial, expected)
def test_select_field(self):
CHOICES = (
('a', 'Option A'),
+148 -26
View File
@@ -1,17 +1,95 @@
import tempfile
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.test import tag, TestCase
from core.models import DataSource, ObjectType
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, TaggedItem
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
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):
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)
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(
vm1.get_config_context(),
vm2.get_config_context()
@@ -458,11 +536,39 @@ class ConfigContextTest(TestCase):
vms[1].get_config_context()
)
def test_valid_local_context_data(self):
device = Device.objects.first()
device.local_context_data = None
device.clean()
device.local_context_data = {"foo": "bar"}
device.clean()
def test_invalid_local_context_data(self):
device = Device.objects.first()
device.local_context_data = ""
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = 0
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = False
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = 'foo'
with self.assertRaises(ValidationError):
device.clean()
@tag('regression')
def test_multiple_tags_return_distinct_objects(self):
"""
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
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.
See https://github.com/netbox-community/netbox/issues/5314
@@ -495,14 +601,15 @@ class ConfigContextTest(TestCase):
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
@tag('regression')
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.
This is combatted by 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
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 to those same two tags result in only a single
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
"""
@@ -543,32 +650,47 @@ class ConfigContextTest(TestCase):
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_valid_local_context_data(self):
@tag('performance', 'regression')
def test_config_context_annotation_query_optimization(self):
"""
Regression test for issue #20327: Ensure config context annotation
doesn't use expensive DISTINCT on main query.
Verifies that DISTINCT is only used in tag subquery where needed,
not on the main device query which is expensive for large datasets.
"""
device = Device.objects.first()
device.local_context_data = None
device.clean()
queryset = Device.objects.filter(pk=device.pk).annotate_config_context_data()
device.local_context_data = {"foo": "bar"}
device.clean()
# Main device query should NOT use DISTINCT
self.assertFalse(queryset.query.distinct)
def test_invalid_local_context_data(self):
device = Device.objects.first()
# Check that tag subqueries DO use DISTINCT by inspecting the annotation
config_annotation = queryset.query.annotations.get('config_context_data')
self.assertIsNotNone(config_annotation)
device.local_context_data = ""
with self.assertRaises(ValidationError):
device.clean()
def find_tag_subqueries(where_node):
"""Find subqueries in WHERE clause that relate to tag filtering"""
subqueries = []
device.local_context_data = 0
with self.assertRaises(ValidationError):
device.clean()
def traverse(node):
if hasattr(node, 'children'):
for child in node.children:
try:
if child.rhs.query.model is TaggedItem:
subqueries.append(child.rhs.query)
except AttributeError:
traverse(child)
traverse(where_node)
return subqueries
device.local_context_data = False
with self.assertRaises(ValidationError):
device.clean()
# Find subqueries in the WHERE clause that should have DISTINCT
tag_subqueries = find_tag_subqueries(config_annotation.query.where)
distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
device.local_context_data = 'foo'
with self.assertRaises(ValidationError):
device.clean()
# Verify we found at least one DISTINCT subquery for tags
self.assertEqual(len(distinct_subqueries), 1)
self.assertTrue(distinct_subqueries[0].distinct)
class ConfigTemplateTest(TestCase):
+142 -1
View File
@@ -1,7 +1,10 @@
from types import SimpleNamespace
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
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 wireless.models import WirelessLANGroup
@@ -17,3 +20,141 @@ class FilenameFromModelTests(TestCase):
for model, expected in cases:
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_'))
+42 -12
View File
@@ -1,15 +1,20 @@
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.models import Q
from taggit.managers import _TaggableManager
from netbox.context import current_request
from .validators import CustomValidator
__all__ = (
'SharedObjectViewMixin',
'filename_from_model',
'image_upload',
'is_report',
'is_script',
@@ -35,13 +40,13 @@ class SharedObjectViewMixin:
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(' ', '_')
return f'netbox_{base}'
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:
base = f"{context['device'].name or 'config'}"
elif 'virtualmachine' in context:
@@ -64,17 +69,42 @@ def is_taggable(obj):
def image_upload(instance, filename):
"""
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.
extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
# Normalize Windows paths and create a Path object.
normalized_filename = str(filename).replace('\\', '/')
file_path = Path(normalized_filename)
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):
@@ -107,7 +137,7 @@ def run_validators(instance, validators):
request = current_request.get()
for validator in validators:
# Loading a validator class by dotted path
# Loading a validator class by a dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
+2 -2
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
@@ -1547,7 +1548,6 @@ class ScriptResultView(TableMixin, generic.ObjectView):
except KeyError:
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
if job.data:
if 'log' in job.data:
if 'tests' in job.data:
tests = job.data['tests']
@@ -1558,7 +1558,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
index += 1
result = {
'index': index,
'time': log.get('time'),
'time': datetime.fromisoformat(log.get('time')),
'status': log.get('status'),
'message': log.get('message'),
'object': log.get('obj'),