Compare commits

...

7 Commits

Author SHA1 Message Date
Martin Hauser
bdd23f3d17 fix(extras): Handle username fallback for job events
Fallback to the associated user when username is missing from job
lifecycle event contexts. Add a regression test to ensure JOB_COMPLETED
webhooks are enqueued without a request context.

Fixes #21371
2026-02-17 08:15:58 -05:00
github-actions
af6e18b7d4 Update source translation strings 2026-02-17 05:26:34 +00:00
Jeremy Stretch
816c5d4bea Fixes #21412: Defer monkey-patching until after settings have been loaded (#21415) 2026-02-16 18:17:50 +01:00
Martin Hauser
f4c3c90bab perf(filters): Avoid ContentType join in ContentTypeFilter
Resolve the ContentType via get_by_natural_key() and filter by the
FK value to prevent an unnecessary join to django_content_type.

Fixes #21420
2026-02-16 12:06:31 -05:00
Martin Hauser
862593f2dd fix(circuits): Persist CircuitType owner field
CircuitTypeForm rendered `owner` twice and did not persist ownership
because the displayed fields didn't match the fields processed by the
form. Remove `owner` from the fieldset and include it in `Meta.fields`
to keep rendering and form processing in sync.

Fixes #21397
2026-02-16 08:54:34 -05:00
Martin Hauser
f4c27fd494 fix(ipam): Use bulk_update in VLANGroup VID range migration
Replace per-row `save()` calls with `bulk_update` when populating
VLANGroup VLAN ID ranges during migration.

This avoids triggering post_save handlers (e.g. search cache/indexing)
on existing VLANGroup records and updates only the relevant fields,
improving both reliability and performance on larger databases.

Fixes #21375
2026-02-16 08:53:16 -05:00
Martin Hauser
ae736ef407 fix(dcim): Render device height as rack units via floatformat
Use `TemplatedAttr` for device height and render using Django's
`floatformat` filter so 0.0 is displayed as `0U` (and whole-U values
omit the decimal).

Fixes #21267
2026-02-16 08:37:50 -05:00
10 changed files with 114 additions and 62 deletions

View File

@@ -91,13 +91,13 @@ class ProviderNetworkForm(PrimaryModelForm):
class CircuitTypeForm(OrganizationalModelForm):
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
FieldSet('name', 'slug', 'color', 'description', 'tags'),
)
class Meta:
model = CircuitType
fields = [
'name', 'slug', 'color', 'description', 'comments', 'tags',
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
]

View File

@@ -126,7 +126,7 @@ class DeviceDeviceTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.RelatedObjectAttr('device_type.manufacturer', linkify=True)
model = attrs.RelatedObjectAttr('device_type', linkify=True)
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
height = attrs.TemplatedAttr('device_type.u_height', template_name='dcim/devicetype/attrs/height.html')
front_image = attrs.ImageAttr('device_type.front_image')
rear_image = attrs.ImageAttr('device_type.rear_image')
@@ -143,7 +143,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
part_number = attrs.TextAttr('part_number')
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
description = attrs.TextAttr('description')
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
full_depth = attrs.BooleanAttr('is_full_depth')
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')

View File

@@ -113,6 +113,17 @@ def enqueue_event(queue, instance, request, event_type):
def process_event_rules(event_rules, object_type, event):
"""
Process a list of EventRules against an event.
Notes on event sources:
- Object change events (created/updated/deleted) are enqueued via
enqueue_event() during an HTTP request.
These events include a request object and legacy request
attributes (e.g. username, request_id) for backward compatibility.
- Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by
job_start/job_end signal handlers and may not include a request
context.
Consumers must not assume that fields like `username` are always
present.
"""
for event_rule in event_rules:
@@ -132,16 +143,22 @@ def process_event_rules(event_rules, object_type, event):
queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
rq_queue = get_queue(queue_name)
# For job lifecycle events, `username` may be absent because
# there is no request context.
# Prefer the associated user object when present, falling
# back to the legacy username attribute.
username = getattr(event.get('user'), 'username', None) or event.get('username')
# Compile the task parameters
params = {
"event_rule": event_rule,
"object_type": object_type,
"event_type": event['event_type'],
"data": event_data,
"snapshots": event.get('snapshots'),
"timestamp": timezone.now().isoformat(),
"username": event['username'],
"retry": get_rq_retry()
'event_rule': event_rule,
'object_type': object_type,
'event_type': event['event_type'],
'data': event_data,
'snapshots': event.get('snapshots'),
'timestamp': timezone.now().isoformat(),
'username': username,
'retry': get_rq_retry(),
}
if 'request' in event:
# Exclude FILES - webhooks don't need uploaded files,
@@ -158,11 +175,12 @@ def process_event_rules(event_rules, object_type, event):
# Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": event['user'],
"data": event_data
'instance': event_rule.action_object,
'name': script.name,
'user': event['user'],
'data': event_data,
}
if 'snapshots' in event:
params['snapshots'] = event['snapshots']
@@ -179,7 +197,7 @@ def process_event_rules(event_rules, object_type, event):
object_type=object_type,
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event['event_type']
event_type=event['event_type'],
)
else:

View File

@@ -1,6 +1,6 @@
import json
import uuid
from unittest.mock import patch
from unittest.mock import Mock, patch
import django_rq
from django.http import HttpResponse
@@ -15,7 +15,8 @@ from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import EventRuleActionChoices
from extras.events import enqueue_event, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook
from extras.models import EventRule, Script, Tag, Webhook
from extras.signals import process_job_end_event_rules
from extras.webhooks import generate_signature, send_webhook
from netbox.context_managers import event_tracking
from utilities.testing import APITestCase
@@ -395,6 +396,36 @@ class EventRuleTest(APITestCase):
with patch.object(Session, 'send', dummy_send):
send_webhook(**job.kwargs)
def test_job_completed_webhook_username_fallback(self):
"""
Ensure job_end event processing can enqueue a webhook even when the EventContext
lacks legacy request attributes (e.g. `username`).
The job_start/job_end signal receivers only populate `user` and `data`, so webhook
processing must derive the username from the user object (or tolerate it being unset).
"""
script_type = ObjectType.objects.get_for_model(Script)
webhook_type = ObjectType.objects.get_for_model(Webhook)
webhook = Webhook.objects.get(name='Webhook 1')
event_rule = EventRule.objects.create(
name='Event Rule Job Completed',
event_types=[JOB_COMPLETED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhook.pk,
)
event_rule.object_types.set([script_type])
# Mimic the `core.job_end` signal sender expected by extras.signals.process_job_end_event_rules
# (notably: no request, and thus no legacy `username`)
sender = Mock(object_type=script_type, data={}, user=self.user)
process_job_end_event_rules(sender)
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], event_rule)
self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED)
self.assertEqual(job.kwargs['object_type'], script_type)
self.assertEqual(job.kwargs['username'], self.user.username)
def test_duplicate_triggers(self):
"""
Test for erroneous duplicate event triggers resulting from saving an object multiple times

View File

@@ -13,10 +13,11 @@ def set_vid_ranges(apps, schema_editor):
VLANGroup = apps.get_model('ipam', 'VLANGroup')
db_alias = schema_editor.connection.alias
for group in VLANGroup.objects.using(db_alias).all():
vlan_groups = VLANGroup.objects.using(db_alias).only('id', 'min_vid', 'max_vid')
for group in vlan_groups:
group.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')]
group._total_vlan_ids = group.max_vid - group.min_vid + 1
group.save()
VLANGroup.objects.using(db_alias).bulk_update(vlan_groups, ['vid_ranges', '_total_vlan_ids'], batch_size=100)
class Migration(migrations.Migration):

View File

@@ -11,14 +11,10 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework.utils import field_mapping
from strawberry_django import pagination
from strawberry_django.fields.field import StrawberryDjangoField
from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
from netbox.plugins import PluginConfig
from netbox.registry import registry
import storages.utils # type: ignore
@@ -28,21 +24,6 @@ from utilities.string import trailing_slash
from .monkey import get_unique_validators
#
# Monkey-patching
#
# TODO: Remove this once #20547 has been implemented
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
#
# Environment setup
#
@@ -969,6 +950,26 @@ for plugin_name in PLUGINS:
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
#
# Monkey-patching
#
from rest_framework.utils import field_mapping # noqa: E402
from strawberry_django import pagination # noqa: E402
from strawberry_django.fields.field import StrawberryDjangoField # noqa: E402
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination # noqa: E402
# TODO: Remove this once #20547 has been implemented
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *

View File

@@ -103,7 +103,7 @@ class TextAttr(ObjectAttribute):
def get_value(self, obj):
value = resolve_attr_path(obj, self.accessor)
# Apply format string (if any)
if value and self.format_string:
if value is not None and value != '' and self.format_string:
return self.format_string.format(value)
return value

View File

@@ -0,0 +1 @@
{{ value|floatformat }}U

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-14 05:17+0000\n"
"POT-Creation-Date: 2026-02-17 05:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -8037,7 +8037,7 @@ msgid "Racks"
msgstr ""
#: netbox/dcim/tables/racks.py:55 netbox/dcim/tables/racks.py:130
#: netbox/dcim/ui/panels.py:30 netbox/dcim/ui/panels.py:146
#: netbox/dcim/ui/panels.py:30
#: netbox/templates/dcim/inc/panels/racktype_dimensions.html:14
msgid "Height"
msgstr ""
@@ -12716,67 +12716,67 @@ msgstr ""
msgid "Cannot delete stores from registry"
msgstr ""
#: netbox/netbox/settings.py:847
#: netbox/netbox/settings.py:828
msgid "Czech"
msgstr ""
#: netbox/netbox/settings.py:848
#: netbox/netbox/settings.py:829
msgid "Danish"
msgstr ""
#: netbox/netbox/settings.py:849
#: netbox/netbox/settings.py:830
msgid "German"
msgstr ""
#: netbox/netbox/settings.py:850
#: netbox/netbox/settings.py:831
msgid "English"
msgstr ""
#: netbox/netbox/settings.py:851
#: netbox/netbox/settings.py:832
msgid "Spanish"
msgstr ""
#: netbox/netbox/settings.py:852
#: netbox/netbox/settings.py:833
msgid "French"
msgstr ""
#: netbox/netbox/settings.py:853
#: netbox/netbox/settings.py:834
msgid "Italian"
msgstr ""
#: netbox/netbox/settings.py:854
#: netbox/netbox/settings.py:835
msgid "Japanese"
msgstr ""
#: netbox/netbox/settings.py:855
#: netbox/netbox/settings.py:836
msgid "Latvian"
msgstr ""
#: netbox/netbox/settings.py:856
#: netbox/netbox/settings.py:837
msgid "Dutch"
msgstr ""
#: netbox/netbox/settings.py:857
#: netbox/netbox/settings.py:838
msgid "Polish"
msgstr ""
#: netbox/netbox/settings.py:858
#: netbox/netbox/settings.py:839
msgid "Portuguese"
msgstr ""
#: netbox/netbox/settings.py:859
#: netbox/netbox/settings.py:840
msgid "Russian"
msgstr ""
#: netbox/netbox/settings.py:860
#: netbox/netbox/settings.py:841
msgid "Turkish"
msgstr ""
#: netbox/netbox/settings.py:861
#: netbox/netbox/settings.py:842
msgid "Ukrainian"
msgstr ""
#: netbox/netbox/settings.py:862
#: netbox/netbox/settings.py:843
msgid "Chinese"
msgstr ""

View File

@@ -165,12 +165,12 @@ class ContentTypeFilter(django_filters.CharFilter):
try:
app_label, model = value.lower().split('.')
except ValueError:
content_type = ContentType.objects.get_by_natural_key(app_label, model)
except (ValueError, ContentType.DoesNotExist):
return qs.none()
return qs.filter(
**{
f'{self.field_name}__app_label': app_label,
f'{self.field_name}__model': model
f'{self.field_name}': content_type,
}
)