From 7b9dd9dc1e60c990353b8039bdd97f6a0b756e11 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:53:20 -0600 Subject: [PATCH 1/3] Dispatch create-path notifications async to fix slow POST latency POST /api/v2/engagements/ takes ~5s on large tenants because create_notification runs recipient enumeration and per-user Alert writes on the request thread. Move the outer create_notification to a Celery worker for the five create-path events (engagement_added, product_added, product_type_added, finding_added, test_added) by adding async_create_notification (accepts ids, re-fetches, delegates) and dispatching via dojo_dispatch_task. This extends the existing per-user async pattern (Slack/email/MSTeams/webhooks) up one level so the recipient query and Alert fan-out no longer block the response. Co-Authored-By: Claude Opus 4.7 (1M context) --- dojo/api_v2/serializers.py | 8 +- dojo/engagement/signals.py | 14 +- dojo/importers/default_importer.py | 11 +- dojo/notifications/helper.py | 41 ++++++ dojo/product/signals.py | 17 ++- dojo/product_type/signals.py | 17 ++- unittests/test_importers_performance.py | 38 ++--- unittests/test_notifications.py | 188 ++++++++++++++++++++---- unittests/test_rest_framework.py | 2 +- 9 files changed, 262 insertions(+), 74 deletions(-) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 7c9bbd6ae79..ccb16d99a35 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -28,6 +28,7 @@ import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_permission from dojo.authorization.roles_permissions import Permissions +from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import from dojo.finding.helper import ( save_endpoints_template, @@ -116,7 +117,7 @@ Vulnerability_Id, get_current_date, ) -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import async_create_notification from dojo.product_announcements import ( LargeScanSizeProductAnnouncement, ScanTypeProductAnnouncement, @@ -2086,10 +2087,11 @@ def create(self, validated_data): jira_helper.push_to_jira(new_finding) # Create a notification - create_notification( + dojo_dispatch_task( + async_create_notification, event="finding_added", title=_("Addition of %s") % new_finding.title, - finding=new_finding, + finding_id=new_finding.id, description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), url=reverse("view_finding", args=(new_finding.id,)), icon="exclamation-triangle", diff --git a/dojo/engagement/signals.py b/dojo/engagement/signals.py index 0d6b8916dd2..cb4e0fc7291 100644 --- a/dojo/engagement/signals.py +++ b/dojo/engagement/signals.py @@ -7,10 +7,11 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from dojo.celery_dispatch import dojo_dispatch_task from dojo.file_uploads.helper import delete_related_files from dojo.models import Engagement, Product from dojo.notes.helper import delete_related_notes -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import async_create_notification, create_notification from dojo.pghistory_models import DojoEvents @@ -18,8 +19,15 @@ def engagement_post_save(sender, instance, created, **kwargs): if created: title = _('Engagement created for "%(product)s": %(name)s') % {"product": instance.product, "name": instance.name} - create_notification(event="engagement_added", title=title, engagement=instance, product=instance.product, - url=reverse("view_engagement", args=(instance.id,)), url_api=reverse("engagement-detail", args=(instance.id,))) + dojo_dispatch_task( + async_create_notification, + event="engagement_added", + title=title, + engagement_id=instance.id, + product_id=instance.product_id, + url=reverse("view_engagement", args=(instance.id,)), + url_api=reverse("engagement-detail", args=(instance.id,)), + ) @receiver(pre_save, sender=Engagement) diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index a57b6884152..4dd8f713b24 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -18,7 +18,7 @@ Test, Test_Import, ) -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import async_create_notification from dojo.utils import get_full_url, perform_product_grading from dojo.validators import clean_tags @@ -140,12 +140,13 @@ def process_scan( ) # Send out some notifications to the user logger.debug("IMPORT_SCAN: Generating notifications") - create_notification( + dojo_dispatch_task( + async_create_notification, event="test_added", title=f"Test created for {self.test.engagement.product}: {self.test.engagement.name}: {self.test}", - test=self.test, - engagement=self.test.engagement, - product=self.test.engagement.product, + test_id=self.test.id, + engagement_id=self.test.engagement_id, + product_id=self.test.engagement.product_id, url=reverse("view_test", args=(self.test.id,)), url_api=reverse("test-detail", args=(self.test.id,)), ) diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index 610993cd3d7..f42164bd229 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -911,6 +911,47 @@ def send_webhooks_notification(event: str, user_id: int | None = None, **kwargs: get_manager_class_instance()._get_manager_instance("webhooks").send_webhooks_notification(event, user=user, **kwargs) +@app.task +def async_create_notification( + event: str, + engagement_id: int | None = None, + product_id: int | None = None, + product_type_id: int | None = None, + finding_id: int | None = None, + test_id: int | None = None, + **kwargs: dict, +) -> None: + # Re-fetch by id so the recipient-enumeration query and per-user Alert writes + # run in the worker rather than the request thread. + if engagement_id is not None: + engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first() + if engagement is None: + return + kwargs["engagement"] = engagement + if product_id is not None: + product = Product.objects.filter(pk=product_id).first() + if product is None: + return + kwargs["product"] = product + if product_type_id is not None: + product_type = Product_Type.objects.filter(pk=product_type_id).first() + if product_type is None: + return + kwargs["product_type"] = product_type + if finding_id is not None: + finding = Finding.objects.filter(pk=finding_id).select_related("test__engagement__product").first() + if finding is None: + return + kwargs["finding"] = finding + if test_id is not None: + test = Test.objects.filter(pk=test_id).select_related("engagement__product").first() + if test is None: + return + kwargs["test"] = test + + create_notification(event=event, **kwargs) + + @app.task(ignore_result=True) def webhook_reactivation(endpoint_id: int, **_kwargs: dict) -> None: get_manager_class_instance()._get_manager_instance("webhooks")._webhook_reactivation(endpoint_id=endpoint_id) diff --git a/dojo/product/signals.py b/dojo/product/signals.py index efcf23da5aa..a3cbd8af1a2 100644 --- a/dojo/product/signals.py +++ b/dojo/product/signals.py @@ -7,9 +7,10 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from dojo.celery_dispatch import dojo_dispatch_task from dojo.labels import get_labels from dojo.models import Product -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import async_create_notification, create_notification from dojo.pghistory_models import DojoEvents from dojo.utils import get_current_user @@ -19,12 +20,14 @@ @receiver(post_save, sender=Product) def product_post_save(sender, instance, created, **kwargs): if created: - create_notification(event="product_added", - title=instance.name, - product=instance, - url=reverse("view_product", args=(instance.id,)), - url_api=reverse("product-detail", args=(instance.id,)), - ) + dojo_dispatch_task( + async_create_notification, + event="product_added", + title=instance.name, + product_id=instance.id, + url=reverse("view_product", args=(instance.id,)), + url_api=reverse("product-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product) diff --git a/dojo/product_type/signals.py b/dojo/product_type/signals.py index 8bb435751d5..9adbfee501c 100644 --- a/dojo/product_type/signals.py +++ b/dojo/product_type/signals.py @@ -8,9 +8,10 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from dojo.celery_dispatch import dojo_dispatch_task from dojo.labels import get_labels from dojo.models import Product_Type -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import async_create_notification, create_notification from dojo.pghistory_models import DojoEvents labels = get_labels() @@ -19,12 +20,14 @@ @receiver(post_save, sender=Product_Type) def product_type_post_save(sender, instance, created, **kwargs): if created: - create_notification(event="product_type_added", - title=instance.name, - product_type=instance, - url=reverse("view_product_type", args=(instance.id,)), - url_api=reverse("product_type-detail", args=(instance.id,)), - ) + dojo_dispatch_task( + async_create_notification, + event="product_type_added", + title=instance.name, + product_type_id=instance.id, + url=reverse("view_product_type", args=(instance.id,)), + url_api=reverse("product_type-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product_Type) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index 1a9c9fc137d..c0f40313292 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -483,9 +483,9 @@ def test_deduplication_performance_pghistory_async(self): self._deduplication_performance( expected_num_queries1=74, - expected_num_async_tasks1=1, - expected_num_queries2=69, - expected_num_async_tasks2=1, + expected_num_async_tasks1=2, + expected_num_queries2=66, + expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -503,10 +503,10 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=81, - expected_num_async_tasks1=1, - expected_num_queries2=77, - expected_num_async_tasks2=1, + expected_num_queries1=89, + expected_num_async_tasks1=2, + expected_num_queries2=82, + expected_num_async_tasks2=2, ) @@ -570,7 +570,7 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=1191, - expected_num_async_tasks1=6, + expected_num_async_tasks1=7, expected_num_queries2=716, expected_num_async_tasks2=17, expected_num_queries3=346, @@ -593,8 +593,8 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=1200, - expected_num_async_tasks1=6, + expected_num_queries1=1208, + expected_num_async_tasks1=7, expected_num_queries2=725, expected_num_async_tasks2=17, expected_num_queries3=355, @@ -618,8 +618,8 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=1210, - expected_num_async_tasks1=8, + expected_num_queries1=1218, + expected_num_async_tasks1=9, expected_num_queries2=735, expected_num_async_tasks2=19, expected_num_queries3=359, @@ -719,9 +719,9 @@ def test_deduplication_performance_pghistory_async(self): self._deduplication_performance( expected_num_queries1=1411, - expected_num_async_tasks1=7, - expected_num_queries2=1016, - expected_num_async_tasks2=7, + expected_num_async_tasks1=8, + expected_num_queries2=1013, + expected_num_async_tasks2=8, check_duplicates=False, # Async mode - deduplication happens later ) @@ -738,8 +738,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=1420, - expected_num_async_tasks1=7, - expected_num_queries2=1132, - expected_num_async_tasks2=7, + expected_num_queries1=1428, + expected_num_async_tasks1=8, + expected_num_queries2=1137, + expected_num_async_tasks2=8, ) diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index 065f488847b..160afbddfb5 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -35,6 +35,7 @@ from dojo.notifications.helper import ( AlertNotificationManger, WebhookNotificationManger, + async_create_notification, create_notification, webhook_status_cleanup, ) @@ -210,6 +211,7 @@ class TestNotificationTriggers(DojoTestCase): def setUp(self): self.notification_tester = Dojo_User.objects.get(username="admin") + @override_settings(CELERY_TASK_ALWAYS_EAGER=True) @patch("dojo.notifications.helper.NotificationManager._process_notifications") def test_product_types(self, mock): @@ -230,6 +232,7 @@ def test_product_types(self, mock): self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The product type "notif prod type" was deleted by admin') self.assertEqual(mock.call_args_list[-1].kwargs["url"], "/product/type") + @override_settings(CELERY_TASK_ALWAYS_EAGER=True) @patch("dojo.notifications.helper.NotificationManager._process_notifications") def test_products(self, mock): @@ -251,6 +254,7 @@ def test_products(self, mock): self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The product "prod name" was deleted by admin') self.assertEqual(mock.call_args_list[-1].kwargs["url"], "/product") + @override_settings(CELERY_TASK_ALWAYS_EAGER=True) @patch("dojo.notifications.helper.NotificationManager._process_notifications") def test_engagements(self, mock): @@ -466,19 +470,18 @@ def test_auditlog_on(self, mock): self.client.delete(reverse("product_type-detail", args=(prod_type.pk,)), format="json") self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The product type "notif prod type API" was deleted by admin') - @patch("dojo.api_v2.serializers.create_notification") - def test_create_calls_notification_with_auto_assigned_reporter(self, mock_create_notification): - """Test that create_notification is called when creating a finding without explicit reporter.""" + @patch("dojo.api_v2.serializers.dojo_dispatch_task") + def test_create_calls_notification_with_auto_assigned_reporter(self, mock_dispatch): + """Dispatch of async_create_notification when creating a finding without explicit reporter.""" payload = self._minimal_create_payload("Finding with auto-assigned reporter notification") response = self.client.post(self.base_url, payload, format="json") self.assertEqual(201, response.status_code, response.content[:1000]) - # Verify notification was called - mock_create_notification.assert_called_once() - call_args = mock_create_notification.call_args + mock_dispatch.assert_called_once() + call_args = mock_dispatch.call_args - # Check the notification parameters + self.assertIs(call_args[0][0], async_create_notification) self.assertEqual(call_args[1]["event"], "finding_added") self.assertEqual(call_args[1]["title"], "Addition of Finding With Auto-Assigned Reporter Notification") self.assertEqual( @@ -487,16 +490,15 @@ def test_create_calls_notification_with_auto_assigned_reporter(self, mock_create ) self.assertEqual(call_args[1]["icon"], "exclamation-triangle") - # Verify the finding was created successfully created_id = response.data.get("id") self.assertIsNotNone(created_id) + self.assertEqual(call_args[1]["finding_id"], created_id) created_finding = Finding.objects.get(id=created_id) self.assertEqual(created_finding.reporter, self.admin) - @patch("dojo.api_v2.serializers.create_notification") - def test_create_calls_notification_with_explicit_reporter(self, mock_create_notification): - """Test that create_notification is called when creating a finding with explicit reporter.""" - # Create another user to use as explicit reporter + @patch("dojo.api_v2.serializers.dojo_dispatch_task") + def test_create_calls_notification_with_explicit_reporter(self, mock_dispatch): + """Dispatch of async_create_notification when creating a finding with explicit reporter.""" explicit_reporter = User.objects.create(username="explicit_reporter", email="reporter@test.com") payload = self._minimal_create_payload("Finding with explicit reporter notification") @@ -505,11 +507,10 @@ def test_create_calls_notification_with_explicit_reporter(self, mock_create_noti response = self.client.post(self.base_url, payload, format="json") self.assertEqual(201, response.status_code, response.content[:1000]) - # Verify notification was called - mock_create_notification.assert_called_once() - call_args = mock_create_notification.call_args + mock_dispatch.assert_called_once() + call_args = mock_dispatch.call_args - # Check the notification parameters + self.assertIs(call_args[0][0], async_create_notification) self.assertEqual(call_args[1]["event"], "finding_added") self.assertEqual(call_args[1]["title"], "Addition of Finding With Explicit Reporter Notification") self.assertEqual( @@ -518,29 +519,27 @@ def test_create_calls_notification_with_explicit_reporter(self, mock_create_noti ) self.assertEqual(call_args[1]["icon"], "exclamation-triangle") - # Verify the finding was created with explicit reporter created_id = response.data.get("id") self.assertIsNotNone(created_id) + self.assertEqual(call_args[1]["finding_id"], created_id) created_finding = Finding.objects.get(id=created_id) self.assertEqual(created_finding.reporter, explicit_reporter) - @patch("dojo.api_v2.serializers.create_notification") - def test_notification_parameters_are_correct(self, mock_create_notification): - """Test that all notification parameters are properly formatted and passed.""" + @patch("dojo.api_v2.serializers.dojo_dispatch_task") + def test_notification_parameters_are_correct(self, mock_dispatch): + """All dispatch parameters for finding_added are properly formatted and passed.""" payload = self._minimal_create_payload("Test Finding for Parameter Validation") response = self.client.post(self.base_url, payload, format="json") self.assertEqual(201, response.status_code, response.content[:1000]) - # Get the created finding to verify URL formation created_id = response.data.get("id") created_finding = Finding.objects.get(id=created_id) - # Verify notification was called with correct parameters - mock_create_notification.assert_called_once() - call_args = mock_create_notification.call_args + mock_dispatch.assert_called_once() + call_args = mock_dispatch.call_args - # Verify all required parameters exist + self.assertIs(call_args[0][0], async_create_notification) self.assertEqual(call_args[1]["event"], "finding_added") self.assertEqual(call_args[1]["title"], "Addition of Test Finding for Parameter Validation") self.assertEqual( @@ -549,7 +548,138 @@ def test_notification_parameters_are_correct(self, mock_create_notification): ) self.assertEqual(call_args[1]["url"], f"/finding/{created_finding.id}") self.assertEqual(call_args[1]["icon"], "exclamation-triangle") - self.assertEqual(call_args[1]["finding"], created_finding) + self.assertEqual(call_args[1]["finding_id"], created_finding.id) + + +@versioned_fixtures +class TestAsyncNotificationDispatch(DojoTestCase): + + """Verify that create-path notifications dispatch async_create_notification with IDs (not model instances).""" + + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.admin = User.objects.get(username="admin") + + @patch("dojo.engagement.signals.dojo_dispatch_task") + def test_engagement_added_dispatches_async(self, mock_dispatch): + prod = Product.objects.first() + eng = Engagement.objects.create(product=prod, target_start=timezone.now(), target_end=timezone.now()) + + mock_dispatch.assert_called_once() + task_arg, = mock_dispatch.call_args[0] + self.assertIs(task_arg, async_create_notification) + kwargs = mock_dispatch.call_args[1] + self.assertEqual(kwargs["event"], "engagement_added") + self.assertEqual(kwargs["engagement_id"], eng.id) + self.assertEqual(kwargs["product_id"], prod.id) + self.assertNotIn("engagement", kwargs) + self.assertNotIn("product", kwargs) + + @patch("dojo.product.signals.dojo_dispatch_task") + def test_product_added_dispatches_async(self, mock_dispatch): + prod_type = Product_Type.objects.first() + prod = Product.objects.create(prod_type=prod_type, description="test", name="async dispatch prod") + + mock_dispatch.assert_called_once() + task_arg, = mock_dispatch.call_args[0] + self.assertIs(task_arg, async_create_notification) + kwargs = mock_dispatch.call_args[1] + self.assertEqual(kwargs["event"], "product_added") + self.assertEqual(kwargs["product_id"], prod.id) + self.assertNotIn("product", kwargs) + + @patch("dojo.product_type.signals.dojo_dispatch_task") + def test_product_type_added_dispatches_async(self, mock_dispatch): + prod_type = Product_Type.objects.create(name="async dispatch prod type") + + mock_dispatch.assert_called_once() + task_arg, = mock_dispatch.call_args[0] + self.assertIs(task_arg, async_create_notification) + kwargs = mock_dispatch.call_args[1] + self.assertEqual(kwargs["event"], "product_type_added") + self.assertEqual(kwargs["product_type_id"], prod_type.id) + self.assertNotIn("product_type", kwargs) + + def test_importer_dispatch_uses_id_kwargs(self): + """Static check that default_importer's test_added dispatch passes *_id kwargs (not model instances). + + The importer's notification dispatch fires deep inside a full scan-import flow; + exercising it end-to-end is covered by integration tests. This guard test + verifies the dispatch call shape so regressions (e.g. passing model instances, + which won't pickle across Celery brokers) are caught quickly. + """ + import inspect # noqa: PLC0415 + + from dojo.importers import default_importer as di # noqa: PLC0415 + + source = inspect.getsource(di) + self.assertIn("async_create_notification", source) + start = source.index('event="test_added"') + block = source[start:start + 500] + for key in ("test_id=", "engagement_id=", "product_id="): + self.assertIn(key, block) + for bad in ("test=self.test,", "engagement=self.test.engagement,", "product=self.test.engagement.product,"): + self.assertNotIn(bad, block) + + +@versioned_fixtures +class TestAsyncNotificationTaskBody(DojoTestCase): + + """Verify async_create_notification re-fetches instances and preserves functional parity.""" + + fixtures = ["dojo_testdata.json"] + + def run(self, result=None): + # Same sync pattern used by TestNotificationWebhooks: run under an impersonated user + # with block_execution=True so downstream dojo_dispatch_task calls execute inline. + testuser = User.objects.get(username="admin") + testuser.usercontactinfo.block_execution = True + testuser.save() + with impersonate(testuser): + super().run(result) + + @patch("dojo.notifications.helper.NotificationManager._process_notifications") + def test_async_task_rehydrates_engagement_and_product(self, mock_process): + prod = Product.objects.first() + eng = Engagement.objects.create(product=prod, target_start=timezone.now(), target_end=timezone.now()) + + # Invoke the task body directly — simulates the worker unpickling id args. + async_create_notification( + event="engagement_added", + engagement_id=eng.id, + product_id=prod.id, + title="x", + url=reverse("view_engagement", args=(eng.id,)), + url_api=reverse("engagement-detail", args=(eng.id,)), + ) + + self.assertGreater(mock_process.call_count, 0) + last = mock_process.call_args_list[-1] + self.assertEqual(last.args[0], "engagement_added") + self.assertEqual(last.kwargs["engagement"].id, eng.id) + self.assertEqual(last.kwargs["product"].id, prod.id) + + @patch("dojo.notifications.helper.NotificationManager._process_notifications") + def test_async_task_returns_silently_on_missing_instance(self, mock_process): + mock_process.reset_mock() + async_create_notification(event="engagement_added", engagement_id=999_999_999, title="x") + mock_process.assert_not_called() + + @patch("dojo.notifications.helper.create_notification") + def test_dispatch_respects_block_execution(self, mock_create): + """With block_execution=True on the impersonated user, the post_save signal runs the task body inline.""" + # The run() wrapper impersonates admin with block_execution=True, so + # dojo_dispatch_task takes the sync branch and the task body calls create_notification + # (module-level helper inside async_create_notification) synchronously. + prod = Product.objects.first() + eng = Engagement.objects.create(product=prod, target_start=timezone.now(), target_end=timezone.now()) + + mock_create.assert_called_once() + kwargs = mock_create.call_args[1] + self.assertEqual(kwargs["event"], "engagement_added") + self.assertEqual(kwargs["engagement"].id, eng.id) + self.assertEqual(kwargs["product"].id, prod.id) @versioned_fixtures @@ -578,7 +708,7 @@ def test_missing_system_webhook(self): with self.assertLogs("dojo.notifications.helper", level="INFO") as cm: manager = WebhookNotificationManger() manager.send_webhooks_notification(event="dummy") - self.assertIn("URLs for Webhooks not configured: skipping system notification", cm.output[1]) + self.assertIn("URLs for Webhooks not configured: skipping system notification", cm.output[-1]) def test_missing_personal_webhook(self): # test data contains 2 entries but we need to test missing definition @@ -586,7 +716,7 @@ def test_missing_personal_webhook(self): with self.assertLogs("dojo.notifications.helper", level="INFO") as cm: manager = WebhookNotificationManger() manager.send_webhooks_notification(event="dummy", user=Dojo_User.objects.get(username="admin")) - self.assertIn("URLs for Webhooks not configured for user '(admin)': skipping user notification", cm.output[1]) + self.assertIn("URLs for Webhooks not configured for user '(admin)': skipping user notification", cm.output[-1]) def test_system_webhook_inactive(self): self.sys_wh.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT @@ -594,7 +724,7 @@ def test_system_webhook_inactive(self): with self.assertLogs("dojo.notifications.helper", level="INFO") as cm: manager = WebhookNotificationManger() manager.send_webhooks_notification(event="dummy") - self.assertIn("URL for Webhook 'My webhook endpoint' is not active: Permanently inactive (inactive_permanent)", cm.output[1]) + self.assertIn("URL for Webhook 'My webhook endpoint' is not active: Permanently inactive (inactive_permanent)", cm.output[-1]) def test_system_webhook_sucessful(self): with self.assertLogs("dojo.notifications.helper", level="DEBUG") as cm: diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 7199e08c126..d1f11fa7b9b 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -2718,7 +2718,7 @@ def __init__(self, *args, **kwargs): } self.update_fields = {"first_name": "test changed", "configuration_permissions": [219, 220]} self.test_type = TestType.CONFIGURATION_PERMISSIONS - self.deleted_objects = 25 + self.deleted_objects = 13 BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_create(self): From 47e6fc65df37d24c7aa3b0c3e829a52596b10f38 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:42:33 -0600 Subject: [PATCH 2/3] Fix ruff D213 and skip dispatch during fixture loads - Reformat async_create_notification importer-guard docstring to D213 style - Skip post_save dispatch when raw=True (loaddata) so the k8s initializer's fixture install path doesn't require an available Celery broker. Without this guard the unconditional async dispatch tries to enqueue during product_type.json load and fails with kombu OperationalError. Co-Authored-By: Claude Opus 4.7 (1M context) --- dojo/engagement/signals.py | 3 +++ dojo/product/signals.py | 3 +++ dojo/product_type/signals.py | 3 +++ unittests/test_notifications.py | 3 ++- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/dojo/engagement/signals.py b/dojo/engagement/signals.py index cb4e0fc7291..d216bbddf7c 100644 --- a/dojo/engagement/signals.py +++ b/dojo/engagement/signals.py @@ -17,6 +17,9 @@ @receiver(post_save, sender=Engagement) def engagement_post_save(sender, instance, created, **kwargs): + # raw=True is set by loaddata; skip dispatch so fixture loading doesn't require a live broker. + if kwargs.get("raw"): + return if created: title = _('Engagement created for "%(product)s": %(name)s') % {"product": instance.product, "name": instance.name} dojo_dispatch_task( diff --git a/dojo/product/signals.py b/dojo/product/signals.py index a3cbd8af1a2..cc43bb01e0e 100644 --- a/dojo/product/signals.py +++ b/dojo/product/signals.py @@ -19,6 +19,9 @@ @receiver(post_save, sender=Product) def product_post_save(sender, instance, created, **kwargs): + # raw=True is set by loaddata; skip dispatch so fixture loading doesn't require a live broker. + if kwargs.get("raw"): + return if created: dojo_dispatch_task( async_create_notification, diff --git a/dojo/product_type/signals.py b/dojo/product_type/signals.py index 9adbfee501c..9c2b13e9901 100644 --- a/dojo/product_type/signals.py +++ b/dojo/product_type/signals.py @@ -19,6 +19,9 @@ @receiver(post_save, sender=Product_Type) def product_type_post_save(sender, instance, created, **kwargs): + # raw=True is set by loaddata; skip dispatch so fixture loading doesn't require a live broker. + if kwargs.get("raw"): + return if created: dojo_dispatch_task( async_create_notification, diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index 160afbddfb5..f5f561021de 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -602,7 +602,8 @@ def test_product_type_added_dispatches_async(self, mock_dispatch): self.assertNotIn("product_type", kwargs) def test_importer_dispatch_uses_id_kwargs(self): - """Static check that default_importer's test_added dispatch passes *_id kwargs (not model instances). + """ + Static check that default_importer's test_added dispatch passes *_id kwargs (not model instances). The importer's notification dispatch fires deep inside a full scan-import flow; exercising it end-to-end is covered by integration tests. This guard test From 12472c5a839f4fe1b10f4bfe25f95f7fb01c5ae7 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 24 Apr 2026 20:04:17 +0200 Subject: [PATCH 3/3] optimize async_create_notification to avoid redundant DB fetches When test_id + engagement_id + product_id are all passed, the original implementation fetched each object independently (3 queries). Since Test.select_related("engagement__product") already loads all three in one query, derive engagement and product from the test instead. Same for engagement_id + product_id: one query instead of two. Also updates the no_async performance test expected counts accordingly. --- dojo/notifications/helper.py | 46 ++++++++++++++++++------- unittests/test_importers_performance.py | 28 +++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index f42164bd229..c7161b81227 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -923,31 +923,51 @@ def async_create_notification( ) -> None: # Re-fetch by id so the recipient-enumeration query and per-user Alert writes # run in the worker rather than the request thread. - if engagement_id is not None: - engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first() - if engagement is None: + # Fetch most-specific first and derive parent objects from the already-loaded + # select_related chain to avoid redundant queries. For example, fetching a + # Test with select_related("engagement__product") covers all three objects in + # one query, so engagement_id and product_id don't need separate fetches. + fetched_engagement = None + fetched_product = None + + if test_id is not None: + test = Test.objects.filter(pk=test_id).select_related("engagement__product").first() + if test is None: return - kwargs["engagement"] = engagement + kwargs["test"] = test + fetched_engagement = test.engagement + fetched_product = test.engagement.product + + if engagement_id is not None: + if fetched_engagement is not None: + kwargs["engagement"] = fetched_engagement + else: + engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first() + if engagement is None: + return + kwargs["engagement"] = engagement + fetched_product = engagement.product + if product_id is not None: - product = Product.objects.filter(pk=product_id).first() - if product is None: - return - kwargs["product"] = product + if fetched_product is not None: + kwargs["product"] = fetched_product + else: + product = Product.objects.filter(pk=product_id).first() + if product is None: + return + kwargs["product"] = product + if product_type_id is not None: product_type = Product_Type.objects.filter(pk=product_type_id).first() if product_type is None: return kwargs["product_type"] = product_type + if finding_id is not None: finding = Finding.objects.filter(pk=finding_id).select_related("test__engagement__product").first() if finding is None: return kwargs["finding"] = finding - if test_id is not None: - test = Test.objects.filter(pk=test_id).select_related("engagement__product").first() - if test is None: - return - kwargs["test"] = test create_notification(event=event, **kwargs) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index c0f40313292..8d5530769b1 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -315,11 +315,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=139, - expected_num_async_tasks1=1, + expected_num_async_tasks1=2, expected_num_queries2=115, expected_num_async_tasks2=1, expected_num_queries3=29, expected_num_async_tasks3=1, + expected_num_queries4=100, + expected_num_async_tasks4=0, ) @override_settings(ENABLE_AUDITLOG=True) @@ -336,12 +338,14 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=146, - expected_num_async_tasks1=1, + expected_num_queries1=152, + expected_num_async_tasks1=2, expected_num_queries2=122, expected_num_async_tasks2=1, expected_num_queries3=36, expected_num_async_tasks3=1, + expected_num_queries4=100, + expected_num_async_tasks4=0, ) @override_settings(ENABLE_AUDITLOG=True) @@ -359,12 +363,14 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=153, - expected_num_async_tasks1=3, + expected_num_queries1=159, + expected_num_async_tasks1=4, expected_num_queries2=129, expected_num_async_tasks2=3, expected_num_queries3=40, expected_num_async_tasks3=3, + expected_num_queries4=107, + expected_num_async_tasks4=2, ) # Deduplication is enabled in the tests above, but to properly test it we must run the same import twice and capture the results. @@ -503,9 +509,9 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=89, + expected_num_queries1=87, expected_num_async_tasks1=2, - expected_num_queries2=82, + expected_num_queries2=80, expected_num_async_tasks2=2, ) @@ -593,7 +599,7 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=1208, + expected_num_queries1=1206, expected_num_async_tasks1=7, expected_num_queries2=725, expected_num_async_tasks2=17, @@ -618,7 +624,7 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=1218, + expected_num_queries1=1216, expected_num_async_tasks1=9, expected_num_queries2=735, expected_num_async_tasks2=19, @@ -738,8 +744,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=1428, + expected_num_queries1=1426, expected_num_async_tasks1=8, - expected_num_queries2=1137, + expected_num_queries2=1135, expected_num_async_tasks2=8, )