mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-05 06:46:25 -06:00
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
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}""",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user