Compare commits

..

2 Commits

Author SHA1 Message Date
Jeremy Stretch
dd8b35e92f Fixes #21531: Fix search functionality for location when combined with other filters 2026-03-05 17:30:36 -05:00
Martin Hauser
93e01d5b07 fix(dcim): Correct object type for child Site Group actions
Replace `dcim.Region` with `dcim.SiteGroup` in child Site Group actions
for the DCIM view. Ensures the correct model is referenced when adding
child Site Groups, improving functionality and aligning with the
expected behavior.

Fixes #21586
2026-03-05 13:59:18 -05:00
17 changed files with 19 additions and 216 deletions

View File

@@ -4,7 +4,7 @@ colorama
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
Django==6.0.*
Django==5.2.*
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -35,9 +35,7 @@ django-pglocks
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
# https://github.com/django-commons/django-prometheus/issues/494
django-prometheus>=2.4.0,<2.5.0,!=2.4.1
django-prometheus
# Django caching backend using Redis
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst

View File

@@ -31,11 +31,6 @@ The following data is available as context for Jinja2 templates:
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:

View File

@@ -88,8 +88,3 @@ The following context variables are available in to the text and link templates.
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.

View File

@@ -43,11 +43,6 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@@ -1,6 +1,4 @@
import django_tables2 as tables
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from core.constants import JOB_LOG_ENTRY_LEVELS
@@ -84,9 +82,3 @@ class JobLogEntryTable(BaseTable):
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')
def render_message(self, record, value):
if record.get('level') == 'error' and '\n' in value:
value = conditional_escape(value)
return mark_safe(f'<pre class="p-0">{value}</pre>')
return value

View File

@@ -306,12 +306,9 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
# extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
# Extend `search()` to include querying on Location.facility
if value.strip():
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
return queryset

View File

@@ -389,7 +389,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]

View File

@@ -1,4 +1,3 @@
import warnings
from datetime import timedelta
from importlib import import_module
@@ -18,12 +17,11 @@ class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options):
warnings.warn(
"\n\nDEPRECATION WARNING\n"
self.stdout.write(
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.\n",
category=FutureWarning,
"will be removed in a future release.",
self.style.WARNING
)
config = Config()

View File

@@ -677,19 +677,15 @@ class ConfigContextTest(TestCase):
if hasattr(node, 'children'):
for child in node.children:
try:
# In Django 6.0+, rhs is a Query directly; older Django wraps it in Subquery
rhs_query = getattr(child.rhs, 'query', child.rhs)
if rhs_query.model is TaggedItem:
subqueries.append(rhs_query)
if child.rhs.query.model is TaggedItem:
subqueries.append(child.rhs.query)
except AttributeError:
traverse(child)
traverse(where_node)
return subqueries
# In Django 6.0+, the annotation is a Query directly; older Django wraps it in Subquery
annotation_query = getattr(config_annotation, 'query', config_annotation)
# Find subqueries in the WHERE clause that should have DISTINCT
tag_subqueries = find_tag_subqueries(annotation_query.where)
tag_subqueries = find_tag_subqueries(config_annotation.query.where)
distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
# Verify we found at least one DISTINCT subquery for tags

View File

@@ -94,11 +94,9 @@ class NetHost(Lookup):
rhs, rhs_params = self.process_rhs(qn, connection)
# Query parameters are automatically converted to IPNetwork objects, which are then turned to strings. We need
# to omit the mask portion of the object's string representation to match PostgreSQL's HOST() function.
# Note: params may be tuples (Django 6.0+) or lists (older Django), so convert before mutating.
rhs_params = list(rhs_params)
if rhs_params:
rhs_params[0] = rhs_params[0].split('/')[0]
params = list(lhs_params) + rhs_params
params = lhs_params + rhs_params
return f'HOST({lhs}) = {rhs}', params

View File

@@ -13,7 +13,7 @@ from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest, PreconditionFailed
from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering
from . import mixins
@@ -34,50 +34,6 @@ HTTP_ACTIONS = {
}
class ETagMixin:
"""
Adds ETag header support to ViewSets. Generates weak ETags (W/ prefix per
RFC 7232 §2.1) from `last_updated` (or `created` if unavailable). Weak ETags
are appropriate here because the tag is derived from a modification timestamp
rather than a hash of the serialized payload.
"""
@staticmethod
def _get_etag(obj):
"""Return a weak ETag string for the given object, or None."""
if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None):
return f'W/"{ts.isoformat()}"'
return None
@staticmethod
def _get_if_match(request):
"""Return the list of If-Match header values (if specified)."""
if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*':
return [e.strip() for e in if_match.split(',')]
return []
def _validate_etag(self, request, instance):
"""Validate the request's ETag"""
if provided := self._get_if_match(request):
current_etag = self._get_etag(instance)
if current_etag and current_etag not in provided:
raise PreconditionFailed(etag=current_etag)
def handle_exception(self, exc):
response = super().handle_exception(exc)
if isinstance(exc, PreconditionFailed) and exc.etag:
response['ETag'] = exc.etag
return response
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
response = Response(serializer.data)
if etag := self._get_etag(instance):
response['ETag'] = etag
return response
class BaseViewSet(GenericViewSet):
"""
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
@@ -139,7 +95,6 @@ class BaseViewSet(GenericViewSet):
class NetBoxReadOnlyModelViewSet(
ETagMixin,
mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin,
drf_mixins.RetrieveModelMixin,
@@ -150,7 +105,6 @@ class NetBoxReadOnlyModelViewSet(
class NetBoxModelViewSet(
ETagMixin,
mixins.BulkUpdateModelMixin,
mixins.BulkDestroyModelMixin,
mixins.ObjectValidationMixin,
@@ -237,14 +191,7 @@ class NetBoxModelViewSet(
serializer = self.get_serializer(qs, many=bulk_create)
headers = self.get_success_headers(serializer.data)
response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
# Add ETag for single-object creation only (bulk returns a list, no single ETag)
if not bulk_create:
if etag := self._get_etag(qs):
response['ETag'] = etag
return response
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
model = self.queryset.model
@@ -264,10 +211,6 @@ class NetBoxModelViewSet(
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object_with_snapshot()
# Enforce If-Match precondition (RFC 9110 §13.1.1)
self._validate_etag(self.request, instance)
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
@@ -278,12 +221,8 @@ class NetBoxModelViewSet(
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs)
response = Response(serializer.data)
if etag := self._get_etag(qs):
response['ETag'] = etag
return response
return Response(serializer.data)
def perform_update(self, serializer):
model = self.queryset.model
@@ -293,11 +232,6 @@ class NetBoxModelViewSet(
# Enforce object-level permissions on save()
try:
with transaction.atomic(using=router.db_for_write(model)):
# Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
# between the initial check in update() and the actual write.
if self._get_if_match(self.request):
locked = model.objects.select_for_update().get(pk=serializer.instance.pk)
self._validate_etag(self.request, locked)
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
@@ -308,9 +242,6 @@ class NetBoxModelViewSet(
def destroy(self, request, *args, **kwargs):
instance = self.get_object_with_snapshot()
# Enforce If-Match precondition (RFC 9110 §13.1.1)
self._validate_etag(request, instance)
# Attach changelog message (if any)
serializer = ChangeLogMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -325,16 +256,7 @@ class NetBoxModelViewSet(
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
try:
with transaction.atomic(using=router.db_for_write(model)):
# Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
# between the initial check in destroy() and the actual delete.
if self._get_if_match(self.request):
locked = model.objects.select_for_update().get(pk=instance.pk)
self._validate_etag(self.request, locked)
super().perform_destroy(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
return super().perform_destroy(instance)
class MPTTLockedMixin:

View File

@@ -1,9 +1,6 @@
import logging
import os
import traceback
from abc import ABC, abstractmethod
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
@@ -24,11 +21,6 @@ __all__ = (
'system_job',
)
# The installation root, e.g. "/opt/netbox/". Used to strip absolute path
# prefixes from traceback file paths before recording them in the job log.
# jobs.py lives at <root>/netbox/netbox/jobs.py, so parents[2] is the root.
_INSTALL_ROOT = str(Path(__file__).resolve().parents[2]) + os.sep
def system_job(interval):
"""
@@ -115,13 +107,6 @@ class JobRunner(ABC):
job.terminate(status=JobStatusChoices.STATUS_FAILED)
except Exception as e:
tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '')
tb_record = logging.makeLogRecord({
'levelno': logging.ERROR,
'levelname': 'ERROR',
'msg': tb_str,
})
job.log(tb_record)
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
if type(e) is JobTimeoutException:
logger.error(e)

View File

@@ -435,7 +435,6 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'django.contrib.postgres',
'django.forms',
'corsheaders',
'debug_toolbar',

View File

@@ -10,7 +10,6 @@ from core.models import DataSource, Job
from utilities.testing import disable_warnings
from ..jobs import *
from ..jobs import _INSTALL_ROOT
class TestJobRunner(JobRunner):
@@ -84,12 +83,6 @@ class JobRunnerTest(JobRunnerTestCase):
self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
self.assertEqual(job.error, repr(ErroredJobRunner.EXP))
self.assertEqual(len(job.log_entries), 1)
self.assertEqual(job.log_entries[0]['level'], 'error')
tb_message = job.log_entries[0]['message']
self.assertIn('Traceback', tb_message)
self.assertIn('Test error', tb_message)
self.assertNotIn(_INSTALL_ROOT, tb_message)
class EnqueueTest(JobRunnerTestCase):

View File

@@ -6,7 +6,6 @@ __all__ = (
'AbortScript',
'AbortTransaction',
'PermissionsViolation',
'PreconditionFailed',
'RQWorkerNotRunningException',
)
@@ -41,20 +40,6 @@ class PermissionsViolation(Exception):
message = "Operation failed due to object-level permissions violation"
class PreconditionFailed(APIException):
"""
Raised when an If-Match precondition is not satisfied (HTTP 412).
Optionally carries the current ETag so it can be included in the response.
"""
status_code = status.HTTP_412_PRECONDITION_FAILED
default_detail = 'Precondition failed.'
default_code = 'precondition_failed'
def __init__(self, detail=None, code=None, etag=None):
super().__init__(detail=detail, code=code)
self.etag = etag
class RQWorkerNotRunningException(APIException):
"""
Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker

View File

@@ -114,12 +114,7 @@ class APIViewTestCases:
# Try GET to permitted object
url = self._get_detail_url(instance1)
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify ETag header is present for objects with timestamps
if issubclass(self.model, ChangeLoggingMixin):
self.assertIn('ETag', response, "ETag header missing from detail response")
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
# Try GET to non-permitted object
url = self._get_detail_url(instance2)
@@ -372,46 +367,6 @@ class APIViewTestCases:
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.message, data['changelog_message'])
def test_update_object_with_etag(self):
"""
PATCH an object using a valid If-Match ETag → expect 200.
PATCH again with the now-stale ETag → expect 412.
"""
if not issubclass(self.model, ChangeLoggingMixin):
self.skipTest("Model does not support ETags")
self.add_permissions(
f'{self.model._meta.app_label}.view_{self.model._meta.model_name}',
f'{self.model._meta.app_label}.change_{self.model._meta.model_name}',
)
instance = self._get_queryset().first()
url = self._get_detail_url(instance)
update_data = self.update_data or getattr(self, 'create_data')[0]
# Fetch current ETag
get_response = self.client.get(url, **self.header)
self.assertHttpStatus(get_response, status.HTTP_200_OK)
etag = get_response.get('ETag')
self.assertIsNotNone(etag, "No ETag returned by GET")
# PATCH with correct ETag → 200
response = self.client.patch(
url, update_data, format='json',
**{**self.header, 'HTTP_IF_MATCH': etag}
)
self.assertHttpStatus(response, status.HTTP_200_OK)
new_etag = response.get('ETag')
self.assertIsNotNone(new_etag)
self.assertNotEqual(etag, new_etag) # ETag must change after update
# PATCH with the old (stale) ETag → 412
with disable_warnings('django.request'):
response = self.client.patch(
url, update_data, format='json',
**{**self.header, 'HTTP_IF_MATCH': etag}
)
self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED)
def test_bulk_update_objects(self):
"""
PATCH a set of objects in a single request.

View File

@@ -1,5 +1,5 @@
colorama==0.4.6
Django==6.0.3
Django==5.2.11
django-cors-headers==4.9.0
django-debug-toolbar==6.2.0
django-filter==25.2
@@ -7,7 +7,7 @@ django-graphiql-debug-toolbar==0.2.0
django-htmx==1.27.0
django-mptt==0.18.0
django-pglocks==1.0.4
django-prometheus==2.4.0
django-prometheus==2.4.1
django-redis==6.0.0
django-rich==2.2.0
django-rq==3.2.2