From 08009df92425464bbf4cf28ee272237ed0fef99c Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Sun, 23 Aug 2026 16:58:33 +0200 Subject: [PATCH 1/4] feat: notify on change Notifications could be attached for start, success and failure, so a playbook that runs nightly in check mode to report drift had nothing to notify on: it succeeds whether or not it found anything. Job templates and organizations gain a changed trigger. A run that reported a change on any host notifies those templates next to the ones for how it ended, so a job that changed something and succeeded notifies both. Ad hoc commands are covered by the organization trigger the same way jobs are, and the job types that do not record per host results never fire it. The message for the trigger is customizable like the others, and defaults to one that says the run reported changes. --- awx/api/serializers.py | 6 +- awx/api/urls/job_template.py | 6 + awx/api/urls/organization.py | 6 + awx/api/views/__init__.py | 4 + awx/api/views/organization.py | 4 + .../0209_notification_templates_changed.py | 20 +++ awx/main/models/ad_hoc_commands.py | 11 +- awx/main/models/jobs.py | 25 +++- awx/main/models/notifications.py | 19 ++- awx/main/models/organization.py | 1 + .../notifications/custom_notification_base.py | 6 + awx/main/notifications/email_backend.py | 4 + awx/main/notifications/grafana_backend.py | 2 + awx/main/notifications/pagerduty_backend.py | 2 + awx/main/notifications/webhook_backend.py | 1 + .../functional/api/test_notifications.py | 54 +++++++++ .../functional/models/test_notifications.py | 114 +++++++++++++++++- awx/ui/src/api/mixins/Notifications.mixin.js | 35 ++++++ .../NotificationList/NotificationList.js | 15 ++- .../NotificationList/NotificationList.test.js | 78 ++++++++++++ .../NotificationList/NotificationListItem.js | 21 +++- .../NotificationListItem.test.js | 27 +++++ .../shared/CustomMessagesSubForm.js | 19 +++ .../shared/NotificationTemplateForm.js | 2 + .../shared/hasCustomMessages.js | 1 + ...otification-template-default-messages.json | 36 ++++++ .../src/screens/Organization/Organization.js | 1 + awx/ui/src/screens/Template/Template.js | 1 + docs/docsite/rst/userguide/notifications.rst | 4 +- 29 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 awx/main/migrations/0209_notification_templates_changed.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3f1b55b42..dc6630925 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1389,6 +1389,7 @@ def get_related(self, obj): notification_templates_success=self.reverse('api:organization_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error=self.reverse('api:organization_notification_templates_error_list', kwargs={'pk': obj.pk}), notification_templates_approvals=self.reverse('api:organization_notification_templates_approvals_list', kwargs={'pk': obj.pk}), + notification_templates_changed=self.reverse('api:organization_notification_templates_changed_list', kwargs={'pk': obj.pk}), object_roles=self.reverse('api:organization_object_roles_list', kwargs={'pk': obj.pk}), access_list=self.reverse('api:organization_access_list', kwargs={'pk': obj.pk}), instance_groups=self.reverse('api:organization_instance_groups_list', kwargs={'pk': obj.pk}), @@ -3473,6 +3474,7 @@ def get_related(self, obj): notification_templates_started=self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}), notification_templates_success=self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error=self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + notification_templates_changed=self.reverse('api:job_template_notification_templates_changed_list', kwargs={'pk': obj.pk}), access_list=self.reverse('api:job_template_access_list', kwargs={'pk': obj.pk}), survey_spec=self.reverse('api:job_template_survey_spec', kwargs={'pk': obj.pk}), labels=self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}), @@ -5378,8 +5380,8 @@ def check_messages(messages): error_list.append(_("Expected dict for 'messages' field, found {}".format(type(messages)))) else: for event in messages: - if event not in ('started', 'success', 'error', 'workflow_approval'): - error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', or 'workflow_approval'").format(event)) + if event not in ('started', 'success', 'error', 'changed', 'workflow_approval'): + error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', 'changed', or 'workflow_approval'").format(event)) continue event_messages = messages[event] if event_messages is None: diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py index e898d9a86..4f3d07049 100644 --- a/awx/api/urls/job_template.py +++ b/awx/api/urls/job_template.py @@ -17,6 +17,7 @@ JobTemplateNotificationTemplatesErrorList, JobTemplateNotificationTemplatesStartedList, JobTemplateNotificationTemplatesSuccessList, + JobTemplateNotificationTemplatesChangedList, JobTemplateInstanceGroupsList, JobTemplateAccessList, JobTemplateObjectRolesList, @@ -49,6 +50,11 @@ JobTemplateNotificationTemplatesSuccessList.as_view(), name='job_template_notification_templates_success_list', ), + path( + '/notification_templates_changed/', + JobTemplateNotificationTemplatesChangedList.as_view(), + name='job_template_notification_templates_changed_list', + ), path('/instance_groups/', JobTemplateInstanceGroupsList.as_view(), name='job_template_instance_groups_list'), path('/access_list/', JobTemplateAccessList.as_view(), name='job_template_access_list'), path('/object_roles/', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'), diff --git a/awx/api/urls/organization.py b/awx/api/urls/organization.py index 131e7b5db..4f007b810 100644 --- a/awx/api/urls/organization.py +++ b/awx/api/urls/organization.py @@ -20,6 +20,7 @@ OrganizationNotificationTemplatesStartedList, OrganizationNotificationTemplatesSuccessList, OrganizationNotificationTemplatesApprovalList, + OrganizationNotificationTemplatesChangedList, OrganizationInstanceGroupsList, OrganizationGalaxyCredentialsList, OrganizationObjectRolesList, @@ -61,6 +62,11 @@ OrganizationNotificationTemplatesApprovalList.as_view(), name='organization_notification_templates_approvals_list', ), + path( + '/notification_templates_changed/', + OrganizationNotificationTemplatesChangedList.as_view(), + name='organization_notification_templates_changed_list', + ), path('/instance_groups/', OrganizationInstanceGroupsList.as_view(), name='organization_instance_groups_list'), path('/galaxy_credentials/', OrganizationGalaxyCredentialsList.as_view(), name='organization_galaxy_credentials_list'), path('/object_roles/', OrganizationObjectRolesList.as_view(), name='organization_object_roles_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0e98bf285..3f46259a7 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -2708,6 +2708,10 @@ class JobTemplateNotificationTemplatesSuccessList(JobTemplateNotificationTemplat relationship = 'notification_templates_success' +class JobTemplateNotificationTemplatesChangedList(JobTemplateNotificationTemplatesAnyList): + relationship = 'notification_templates_changed' + + class JobTemplateCredentialsList(SubListCreateAttachDetachAPIView): model = models.Credential serializer_class = serializers.CredentialSerializer diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index 83594f240..fc1e83811 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -196,6 +196,10 @@ class OrganizationNotificationTemplatesApprovalList(OrganizationNotificationTemp relationship = 'notification_templates_approvals' +class OrganizationNotificationTemplatesChangedList(OrganizationNotificationTemplatesAnyList): + relationship = 'notification_templates_changed' + + class OrganizationInstanceGroupsList(OrganizationInstanceGroupMembershipMixin, SubListAttachDetachAPIView): model = InstanceGroup serializer_class = InstanceGroupSerializer diff --git a/awx/main/migrations/0209_notification_templates_changed.py b/awx/main/migrations/0209_notification_templates_changed.py new file mode 100644 index 000000000..21bf8505f --- /dev/null +++ b/awx/main/migrations/0209_notification_templates_changed.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0208_alter_job_skip_tags_alter_jobtemplate_skip_tags'), + ] + + operations = [ + migrations.AddField( + model_name='jobtemplate', + name='notification_templates_changed', + field=models.ManyToManyField(blank=True, related_name='jobtemplate_notification_templates_for_changed', to='main.notificationtemplate'), + ), + migrations.AddField( + model_name='organization', + name='notification_templates_changed', + field=models.ManyToManyField(blank=True, related_name='organization_notification_templates_for_changed', to='main.notificationtemplate'), + ), + ] diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 0583e07c0..5d1db1a62 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -166,7 +166,7 @@ def get_ui_url(self): @property def notification_templates(self): all_orgs = {h.inventory.organization for h in self.hosts.all()} - active_templates = dict(error=set(), success=set(), started=set()) + active_templates = dict(error=set(), success=set(), started=set(), changed=set()) base_notification_templates = NotificationTemplate.objects for org in all_orgs: for templ in base_notification_templates.filter(organization_notification_templates_for_errors=org): @@ -175,9 +175,12 @@ def notification_templates(self): active_templates['success'].add(templ) for templ in base_notification_templates.filter(organization_notification_templates_for_started=org): active_templates['started'].add(templ) + for templ in base_notification_templates.filter(organization_notification_templates_for_changed=org): + active_templates['changed'].add(templ) active_templates['error'] = list(active_templates['error']) active_templates['success'] = list(active_templates['success']) active_templates['started'] = list(active_templates['started']) + active_templates['changed'] = list(active_templates['changed']) return active_templates def get_passwords_needed_to_start(self): @@ -251,3 +254,9 @@ def get_notification_templates(self): def get_notification_friendly_name(self): return "AdHoc Command" + + def has_changes(self): + """ + Whether the command reported a change on any host, the same way a job does. + """ + return self.job_host_summaries.filter(changed__gt=0).exists() diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index cb937b949..9fc208cb5 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -328,6 +328,11 @@ class Meta: "groups will be applied." ), ) + notification_templates_changed = models.ManyToManyField( + "NotificationTemplate", + blank=True, + related_name='%(class)s_notification_templates_for_changed', + ) @classmethod def _get_unified_job_class(cls): @@ -618,6 +623,9 @@ def notification_templates(self): success_notification_templates = list( base_notification_templates.filter(unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]) ) + # Changes are reported by the job itself, so this trigger only exists on the job + # template and on its organization, not on the project. + changed_notification_templates = list(base_notification_templates.filter(jobtemplate_notification_templates_for_changed__in=[self])) # Get Organization NotificationTemplates if self.organization is not None: error_notification_templates = set( @@ -629,7 +637,15 @@ def notification_templates(self): success_notification_templates = set( success_notification_templates + list(base_notification_templates.filter(organization_notification_templates_for_success=self.organization)) ) - return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates)) + changed_notification_templates = set( + changed_notification_templates + list(base_notification_templates.filter(organization_notification_templates_for_changed=self.organization)) + ) + return dict( + error=list(error_notification_templates), + started=list(started_notification_templates), + success=list(success_notification_templates), + changed=list(changed_notification_templates), + ) ''' RelatedJobsMixin @@ -929,6 +945,13 @@ def get_notification_templates(self): def get_notification_friendly_name(self): return "Job" + def has_changes(self): + """ + Whether the run reported a change on any host. Check mode counts, which is what + makes this useful for a compliance playbook that is only meant to report drift. + """ + return self.job_host_summaries.filter(changed__gt=0).exists() + def get_hosts_for_fact_cache(self): """ Builds the queryset to use for writing or finalizing the fact cache diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index f523d3a1e..70eaf7c38 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -244,7 +244,7 @@ def get_absolute_url(self, request=None): class JobNotificationMixin(object): - STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success', 'running': 'started', 'failed': 'error'} + STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success', 'running': 'started', 'failed': 'error', 'changed': 'changed'} # Maximum number of host names exposed in the notification context to keep # payloads bounded for jobs run against very large inventories. HOST_LIST_MAX = 1000 @@ -462,6 +462,13 @@ def get_notification_templates(self): def get_notification_friendly_name(self): raise RuntimeError("Define me") + def has_changes(self): + """ + Whether the run reported a change. Only job types that record per host results can + answer this, so everything else never triggers the changed notifications. + """ + return False + def notification_data(self): raise RuntimeError("Define me") @@ -520,8 +527,8 @@ def build_notification_message(self, nt, status): def send_notification_templates(self, status): from awx.main.tasks.system import send_notifications # avoid circular import - if status not in ['running', 'succeeded', 'failed']: - raise ValueError(_("status must be either running, succeeded or failed")) + if status not in ['running', 'succeeded', 'failed', 'changed']: + raise ValueError(_("status must be either running, succeeded, failed or changed")) try: notification_templates = self.get_notification_templates() except Exception: @@ -531,6 +538,12 @@ def send_notification_templates(self, status): if not notification_templates: return + # A run that changed something notifies the templates set up for changes on top of + # the ones for how it ended, so that a playbook run in check mode that reports drift + # is notified even though it succeeded. + if status in ('succeeded', 'failed') and notification_templates.get('changed') and self.has_changes(): + self.send_notification_templates('changed') + for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])): msg, body = self.build_notification_message(nt, status) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index ad2cb077d..547a4ee29 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -43,6 +43,7 @@ class Meta: help_text=_('Maximum number of hosts allowed to be managed by this organization.'), ) notification_templates_approvals = models.ManyToManyField("NotificationTemplate", blank=True, related_name='%(class)s_notification_templates_for_approvals') + notification_templates_changed = models.ManyToManyField("NotificationTemplate", blank=True, related_name='%(class)s_notification_templates_for_changed') default_environment = models.ForeignKey( 'ExecutionEnvironment', null=True, diff --git a/awx/main/notifications/custom_notification_base.py b/awx/main/notifications/custom_notification_base.py index bbf5b3742..62dd4ef00 100644 --- a/awx/main/notifications/custom_notification_base.py +++ b/awx/main/notifications/custom_notification_base.py @@ -7,6 +7,11 @@ class CustomNotificationBase(object): DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}" + DEFAULT_CHANGED_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' reported changes: {{ url }}" + DEFAULT_CHANGED_BODY = ( + "{{ job_friendly_name }} #{{ job.id }} reported changes and had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}" + ) + DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}' DEFAULT_APPROVAL_RUNNING_BODY = ( 'The approval node "{{ approval_node_name }}" needs review. This approval node can be viewed at: {{ workflow_url }}' @@ -27,6 +32,7 @@ class CustomNotificationBase(object): "started": {"message": DEFAULT_MSG, "body": None}, "success": {"message": DEFAULT_MSG, "body": None}, "error": {"message": DEFAULT_MSG, "body": None}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": None}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None}, diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 902a8b812..4fe440373 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -8,6 +8,9 @@ DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG +DEFAULT_CHANGED_BODY = CustomNotificationBase.DEFAULT_CHANGED_BODY + DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -40,6 +43,7 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase): "started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": DEFAULT_CHANGED_BODY}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/grafana_backend.py b/awx/main/notifications/grafana_backend.py index 2fcee0be8..714408bc2 100644 --- a/awx/main/notifications/grafana_backend.py +++ b/awx/main/notifications/grafana_backend.py @@ -15,6 +15,7 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBase DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -41,6 +42,7 @@ class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, "success": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, "error": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, + "changed": {"body": DEFAULT_BODY, "message": DEFAULT_CHANGED_MSG}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/pagerduty_backend.py b/awx/main/notifications/pagerduty_backend.py index d8f37c07f..09bc3893f 100644 --- a/awx/main/notifications/pagerduty_backend.py +++ b/awx/main/notifications/pagerduty_backend.py @@ -12,6 +12,7 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBase DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -43,6 +44,7 @@ class PagerDutyBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": DEFAULT_BODY}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index bb6211339..91377e595 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -34,6 +34,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"body": DEFAULT_BODY}, "success": {"body": DEFAULT_BODY}, "error": {"body": DEFAULT_BODY}, + "changed": {"body": DEFAULT_BODY}, "workflow_approval": { "running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. This node can be viewed at: {{ workflow_url }}"}'}, "approved": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was approved. {{ workflow_url }}"}'}, diff --git a/awx/main/tests/functional/api/test_notifications.py b/awx/main/tests/functional/api/test_notifications.py index 431065396..4db630a71 100644 --- a/awx/main/tests/functional/api/test_notifications.py +++ b/awx/main/tests/functional/api/test_notifications.py @@ -163,3 +163,57 @@ def test_post_wfj_notification(get, post, admin, workflow_job, notification): response = get(url, admin) assert response.status_code == 200 assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_get_jt_changed_notification(get, admin, job_template): + url = reverse('api:job_template_notification_templates_changed_list', kwargs={'pk': job_template.pk}) + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + +@pytest.mark.django_db +def test_post_jt_changed_notification(get, post, admin, notification_template, job_template): + url = reverse('api:job_template_notification_templates_changed_list', kwargs={'pk': job_template.pk}) + response = post(url, dict(id=notification_template.id, associate=True), admin) + assert response.status_code == 204 + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_get_org_changed_notification(get, admin, organization): + url = reverse('api:organization_notification_templates_changed_list', kwargs={'pk': organization.pk}) + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + +@pytest.mark.django_db +def test_post_org_changed_notification(get, post, admin, notification_template, organization): + url = reverse('api:organization_notification_templates_changed_list', kwargs={'pk': organization.pk}) + response = post(url, dict(id=notification_template.id, associate=True), admin) + assert response.status_code == 204 + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_jt_notification_templates_include_the_changed_trigger(job_template, organization, notification_template): + job_template.organization = organization + job_template.save() + job_template.notification_templates_changed.add(notification_template) + + assert job_template.notification_templates['changed'] == [notification_template] + + +@pytest.mark.django_db +def test_org_changed_notification_templates_reach_its_job_templates(job_template, organization, notification_template): + job_template.organization = organization + job_template.save() + organization.notification_templates_changed.add(notification_template) + + assert job_template.notification_templates['changed'] == [notification_template] diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 6efc0e1c6..66aa14c4e 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -5,7 +5,17 @@ import pytest # from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin -from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, NotificationTemplate, ProjectUpdate, Schedule, SystemJob, WorkflowJob +from awx.main.models import ( + AdHocCommand, + InventoryUpdate, + Job, + JobNotificationMixin, + NotificationTemplate, + ProjectUpdate, + Schedule, + SystemJob, + WorkflowJob, +) from awx.api.serializers import UnifiedJobSerializer @@ -181,3 +191,105 @@ def check_structure_and_completeness(expected_structure, obj): context_stub = JobNotificationMixin.context_stub() check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub) + + +class TestChangedNotifications(object): + """The changed trigger fires for a run that reported a change on any host, next to the + trigger for how the run ended, so a compliance playbook run in check mode reports drift + without having to fail to be noticed.""" + + def notification_template(self, name): + return NotificationTemplate.objects.create( + name=name, + notification_type='webhook', + notification_configuration=dict(url='http://localhost', username='', password='', headers={}), + ) + + def notified_templates(self, build_notification_message): + return set(call[0][0] for call in build_notification_message.call_args_list) + + @pytest.mark.django_db + def test_job_without_changes(self): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + + assert job.has_changes() is False + + @pytest.mark.django_db + def test_job_with_changes(self): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + job.job_host_summaries.create(host_name='host-b', failed=False, ok=1, changed=2, failures=0) + + assert job.has_changes() is True + + @pytest.mark.django_db + @pytest.mark.parametrize('JobClass', [InventoryUpdate, ProjectUpdate, SystemJob, WorkflowJob]) + def test_job_types_without_host_results_never_report_changes(self, JobClass): + assert JobClass.objects.create(name='fake-job').has_changes() is False + + @pytest.mark.django_db + def test_ad_hoc_command_with_changes(self): + command = AdHocCommand.objects.create(name='fake-command') + command.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + + assert command.has_changes() is True + + @pytest.mark.django_db + def test_changed_templates_are_notified_next_to_the_outcome(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + success = self.notification_template('on-success') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'success': [success], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('succeeded') + + assert self.notified_templates(build) == {success, changed} + + @pytest.mark.django_db + def test_a_job_that_changed_nothing_only_notifies_the_outcome(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + success = self.notification_template('on-success') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'success': [success], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('succeeded') + + assert self.notified_templates(build) == {success} + + @pytest.mark.django_db + def test_a_failed_job_that_changed_something_notifies_both(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=True, ok=1, changed=1, failures=1) + error = self.notification_template('on-error') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'error': [error], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('failed') + + assert self.notified_templates(build) == {error, changed} + + @pytest.mark.django_db + def test_the_start_of_a_job_does_not_notify_the_changed_templates(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + started = self.notification_template('on-started') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'started': [started], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('running') + + assert self.notified_templates(build) == {started} + + @pytest.mark.django_db + def test_an_unknown_status_is_refused(self): + job = Job.objects.create(name='fake-job') + + with pytest.raises(ValueError): + job.send_notification_templates('bogus') diff --git a/awx/ui/src/api/mixins/Notifications.mixin.js b/awx/ui/src/api/mixins/Notifications.mixin.js index c3782e2d8..32dd3ca63 100644 --- a/awx/ui/src/api/mixins/Notifications.mixin.js +++ b/awx/ui/src/api/mixins/Notifications.mixin.js @@ -32,6 +32,13 @@ const NotificationsMixin = (parent) => ); } + readNotificationTemplatesChanged(id, params) { + return this.http.get( + `${this.baseUrl}${id}/notification_templates_changed/`, + { params } + ); + } + associateNotificationTemplatesStarted(resourceId, notificationId) { return this.http.post( `${this.baseUrl}${resourceId}/notification_templates_started/`, @@ -74,6 +81,20 @@ const NotificationsMixin = (parent) => ); } + associateNotificationTemplatesChanged(resourceId, notificationId) { + return this.http.post( + `${this.baseUrl}${resourceId}/notification_templates_changed/`, + { id: notificationId } + ); + } + + disassociateNotificationTemplatesChanged(resourceId, notificationId) { + return this.http.post( + `${this.baseUrl}${resourceId}/notification_templates_changed/`, + { id: notificationId, disassociate: true } + ); + } + /** * This is a helper method meant to simplify setting the "on" status of * a related notification. @@ -115,6 +136,13 @@ const NotificationsMixin = (parent) => ); } + if (notificationType === 'changed') { + return this.associateNotificationTemplatesChanged( + resourceId, + notificationId + ); + } + throw new Error( `Unsupported notificationType for association: ${notificationType}` ); @@ -161,6 +189,13 @@ const NotificationsMixin = (parent) => ); } + if (notificationType === 'changed') { + return this.disassociateNotificationTemplatesChanged( + resourceId, + notificationId + ); + } + throw new Error( `Unsupported notificationType for disassociation: ${notificationType}` ); diff --git a/awx/ui/src/components/NotificationList/NotificationList.js b/awx/ui/src/components/NotificationList/NotificationList.js index 77a317c4a..cc0ffb7b0 100644 --- a/awx/ui/src/components/NotificationList/NotificationList.js +++ b/awx/ui/src/components/NotificationList/NotificationList.js @@ -26,6 +26,7 @@ function NotificationList({ id, showApprovalsToggle = false, + showChangedToggle = false, }) { const { t } = useLingui(); const location = useLocation(); @@ -38,6 +39,7 @@ function NotificationList({ notifications, itemCount, approvalsTemplateIds, + changedTemplateIds, startedTemplateIds, successTemplateIds, errorTemplateIds, @@ -106,12 +108,21 @@ function NotificationList({ rtnObj.approvalsTemplateIds = []; } + if (showChangedToggle) { + const { data: changedTemplates } = + await apiModel.readNotificationTemplatesChanged(id, idMatchParams); + rtnObj.changedTemplateIds = changedTemplates.results.map((ch) => ch.id); + } else { + rtnObj.changedTemplateIds = []; + } + return rtnObj; - }, [apiModel, id, location, showApprovalsToggle]), + }, [apiModel, id, location, showApprovalsToggle, showChangedToggle]), { notifications: [], itemCount: 0, approvalsTemplateIds: [], + changedTemplateIds: [], startedTemplateIds: [], successTemplateIds: [], errorTemplateIds: [], @@ -231,11 +242,13 @@ function NotificationList({ } toggleNotification={handleNotificationToggle} approvalsTurnedOn={approvalsTemplateIds.includes(notification.id)} + changedTurnedOn={changedTemplateIds.includes(notification.id)} errorTurnedOn={errorTemplateIds.includes(notification.id)} startedTurnedOn={startedTemplateIds.includes(notification.id)} successTurnedOn={successTemplateIds.includes(notification.id)} typeLabels={typeLabels} showApprovalsToggle={showApprovalsToggle} + showChangedToggle={showChangedToggle} rowIndex={index} /> )} diff --git a/awx/ui/src/components/NotificationList/NotificationList.test.js b/awx/ui/src/components/NotificationList/NotificationList.test.js index bde2efdbd..cf1e92330 100644 --- a/awx/ui/src/components/NotificationList/NotificationList.test.js +++ b/awx/ui/src/components/NotificationList/NotificationList.test.js @@ -197,3 +197,81 @@ describe('', () => { expect(within(errorDialog).getByText('Details')).toBeInTheDocument(); }); }); + +describe('', () => { + let container; + let user; + const data = { + count: 1, + results: [ + { + id: 1, + name: 'Notification one', + url: '/api/v2/notification_templates/1/', + notification_type: 'email', + }, + ], + }; + + const toggle = (id) => container.querySelector(`#${id}`); + + beforeEach(async () => { + NotificationTemplatesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: { + notification_type: { + choices: [['email', 'Email']], + }, + }, + }, + }, + }); + + NotificationTemplatesAPI.read.mockReturnValue({ data }); + + JobTemplatesAPI.readNotificationTemplatesSuccess.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesError.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesStarted.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesChanged.mockReturnValue({ + data: { results: [{ id: 1 }] }, + }); + + ({ container, user } = renderWithContexts( + + )); + + await waitFor(() => + expect(toggle('notification-1-changed-toggle')).toBeInTheDocument() + ); + }); + + test('should show the changed toggle as configured', () => { + expect(JobTemplatesAPI.readNotificationTemplatesChanged).toHaveBeenCalled(); + expect(toggle('notification-1-changed-toggle')).toBeChecked(); + }); + + test('should disable changed notification', async () => { + await user.click(toggle('notification-1-changed-toggle')); + expect( + JobTemplatesAPI.disassociateNotificationTemplate + ).toHaveBeenCalledWith(1, 1, 'changed'); + await waitFor(() => + expect(toggle('notification-1-changed-toggle')).not.toBeChecked() + ); + }); +}); diff --git a/awx/ui/src/components/NotificationList/NotificationListItem.js b/awx/ui/src/components/NotificationList/NotificationListItem.js index 41b4725bd..182e2a030 100644 --- a/awx/ui/src/components/NotificationList/NotificationListItem.js +++ b/awx/ui/src/components/NotificationList/NotificationListItem.js @@ -15,10 +15,12 @@ function NotificationListItem({ startedTurnedOn = false, successTurnedOn = false, errorTurnedOn = false, + changedTurnedOn = false, toggleNotification, typeLabels, showApprovalsToggle = false, + showChangedToggle = false, }) { const { t } = useLingui(); return ( @@ -32,7 +34,10 @@ function NotificationListItem({ {typeLabels[notification.notification_type]} - + + + + toggleNotification(notification.id, changedTurnedOn, 'changed') + } + aria-label={t`Toggle notification changed`} + /> + ); diff --git a/awx/ui/src/components/NotificationList/NotificationListItem.test.js b/awx/ui/src/components/NotificationList/NotificationListItem.test.js index 9ab571392..f7371e3dd 100644 --- a/awx/ui/src/components/NotificationList/NotificationListItem.test.js +++ b/awx/ui/src/components/NotificationList/NotificationListItem.test.js @@ -51,6 +51,33 @@ describe('', () => { expect(screen.getAllByRole('switch')).toHaveLength(4); }); + test('shows changed toggle when configured', () => { + setup({ showChangedToggle: true }); + expect(screen.getAllByRole('switch')).toHaveLength(4); + }); + + test('handles changed click when toggle is on', async () => { + const { user } = setup({ + showChangedToggle: true, + changedTurnedOn: true, + }); + await user.click( + screen.getByRole('switch', { name: 'Toggle notification changed' }) + ); + expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'changed'); + }); + + test('handles changed click when toggle is off', async () => { + const { user } = setup({ + showChangedToggle: true, + changedTurnedOn: false, + }); + await user.click( + screen.getByRole('switch', { name: 'Toggle notification changed' }) + ); + expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'changed'); + }); + test('displays correct type', () => { setup(); expect(screen.getByText('Slack')).toBeInTheDocument(); diff --git a/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js b/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js index f0e1629c7..78e8ce949 100644 --- a/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js +++ b/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js @@ -40,6 +40,7 @@ function CustomMessagesSubForm({ defaultMessages, type }) { resetFields('messages.started', defs.started); resetFields('messages.success', defs.success); resetFields('messages.error', defs.error); + resetFields('messages.changed', defs.changed); resetFields( 'messages.workflow_approval.approved', defs.workflow_approval.approved @@ -162,6 +163,24 @@ function CustomMessagesSubForm({ defaultMessages, type }) { rows={6} /> )} + {showMessages && ( + + )} + {showBodies && ( + + )} {showMessages && ( } /> diff --git a/awx/ui/src/screens/Template/Template.js b/awx/ui/src/screens/Template/Template.js index 24783fe5d..c93fae254 100644 --- a/awx/ui/src/screens/Template/Template.js +++ b/awx/ui/src/screens/Template/Template.js @@ -244,6 +244,7 @@ function Template({ setBreadcrumb }) { id={Number(templateId)} canToggleNotifications={isNotifAdmin} apiModel={JobTemplatesAPI} + showChangedToggle /> } /> diff --git a/docs/docsite/rst/userguide/notifications.rst b/docs/docsite/rst/userguide/notifications.rst index a599c2830..febf84028 100644 --- a/docs/docsite/rst/userguide/notifications.rst +++ b/docs/docsite/rst/userguide/notifications.rst @@ -23,7 +23,7 @@ A Notification is a manifestation of the notification template; for example, whe At a high level, the typical flow for the notification system works as follows: - A user creates a notification template to the REST API at the ``/api/v2/notification_templates`` endpoint (either through the API or through the UI). -- A user assigns the notification template to any of the various objects that support it (all variants of job templates as well as organizations and projects) and at the appropriate trigger level for which they want the notification (started, success, or error). For example a user may wish to assign a particular notification template to trigger when Job Template 1 fails. In which case, they will associate the notification template with the job template at ``/api/v2/job_templates/n/notification_templates_error`` API endpoint. +- A user assigns the notification template to any of the various objects that support it (all variants of job templates as well as organizations and projects) and at the appropriate trigger level for which they want the notification (started, success, error, or, for job templates and organizations, changed). For example a user may wish to assign a particular notification template to trigger when Job Template 1 fails. In which case, they will associate the notification template with the job template at ``/api/v2/job_templates/n/notification_templates_error`` API endpoint. - You can set notifications on job start, not just job end. Users and teams are also able to define their own notifications that can be attached to arbitrary jobs. @@ -536,6 +536,8 @@ For workflow templates that have approval nodes, in addition to *Start*, *Succes Refer to :ref:`ug_wf_approval_nodes` for additional detail on working with these types of nodes. +Job templates and organizations also offer a *Changed* trigger, which notifies when a run reported a change on any host. On an organization it covers the jobs and ad hoc commands run under it. It fires next to the trigger for how the run ended, so a job that changed something and succeeded notifies both *Success* and *Changed*. A run in check mode reports the changes it would have made, which makes this the trigger to use for a hardening or compliance playbook that runs nightly and should only be reported on when it finds drift. The API endpoint for the association is ``/api/v2/job_templates/n/notification_templates_changed`` and ``/api/v2/organizations/n/notification_templates_changed``. + Configure the ``host`` hostname for notifications ======================================================== From 0e9034356d17806dc79a4a59597f53b6b0af6854 Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Mon, 24 Aug 2026 09:00:29 +0200 Subject: [PATCH 2/4] fix: read ad hoc command changes from events, and fix the review findings --- awx/main/models/ad_hoc_commands.py | 5 +++-- .../tests/functional/models/test_notifications.py | 15 ++++++++++++--- awx/ui/src/api/mixins/Notifications.mixin.js | 6 ++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 5d1db1a62..38e273e0b 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -257,6 +257,7 @@ def get_notification_friendly_name(self): def has_changes(self): """ - Whether the command reported a change on any host, the same way a job does. + Whether the command reported a change on any host. An ad hoc command has no + JobHostSummary rows, so this reads the per host events instead. """ - return self.job_host_summaries.filter(changed__gt=0).exists() + return self.ad_hoc_command_events.filter(changed=True).exists() diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 66aa14c4e..b1ead8dc9 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -223,15 +223,24 @@ def test_job_with_changes(self): assert job.has_changes() is True - @pytest.mark.django_db @pytest.mark.parametrize('JobClass', [InventoryUpdate, ProjectUpdate, SystemJob, WorkflowJob]) def test_job_types_without_host_results_never_report_changes(self, JobClass): - assert JobClass.objects.create(name='fake-job').has_changes() is False + # these inherit JobNotificationMixin.has_changes, which answers without a query, + # so an unsaved instance is enough and no required relations have to be invented + assert JobClass().has_changes() is False + + @pytest.mark.django_db + def test_ad_hoc_command_without_changes(self): + command = AdHocCommand.objects.create(name='fake-command') + command.ad_hoc_command_events.create(host_name='host-a', event='runner_on_ok', changed=False) + + assert command.has_changes() is False @pytest.mark.django_db def test_ad_hoc_command_with_changes(self): command = AdHocCommand.objects.create(name='fake-command') - command.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + command.ad_hoc_command_events.create(host_name='host-a', event='runner_on_ok', changed=False) + command.ad_hoc_command_events.create(host_name='host-b', event='runner_on_ok', changed=True) assert command.has_changes() is True diff --git a/awx/ui/src/api/mixins/Notifications.mixin.js b/awx/ui/src/api/mixins/Notifications.mixin.js index 32dd3ca63..49d0ffd44 100644 --- a/awx/ui/src/api/mixins/Notifications.mixin.js +++ b/awx/ui/src/api/mixins/Notifications.mixin.js @@ -101,7 +101,8 @@ const NotificationsMixin = (parent) => * * @param[resourceId] - id of the base resource * @param[notificationId] - id of the notification - * @param[notificationType] - the type of notification, options are "success" and "error" + * @param[notificationType] - the type of notification, options are "approvals", + * "started", "success", "error" and "changed" */ associateNotificationTemplate( resourceId, @@ -154,7 +155,8 @@ const NotificationsMixin = (parent) => * * @param[resourceId] - id of the base resource * @param[notificationId] - id of the notification - * @param[notificationType] - the type of notification, options are "success" and "error" + * @param[notificationType] - the type of notification, options are "approvals", + * "started", "success", "error" and "changed" */ disassociateNotificationTemplate( resourceId, From 5dbfcda4935a527593e17062a1dcf570c97ff30c Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Wed, 26 Aug 2026 13:48:11 +0200 Subject: [PATCH 3/4] fix: address the review findings on the changed notification trigger Three things came out of the review: - 0209 hard-coded the resolved related_name values, jobtemplate_... and organization_..., while the models declare the %(class)s form. Django's autodetector compares them literally, so makemigrations wanted a corrective 0210 AlterField. related_name is ORM-only metadata, so the migration as already applied stays valid and the schema does not change. - NotificationTemplateForm reads defs.changed the way it reads the other message keys, and the fixture in NotificationTemplateForm.test.js had no changed key, so the form crashed on render and took 10 pre-existing tests with it. The real prop comes from server OPTIONS, which now always includes it, so the fixture is what was stale. - NotificationTemplate.default_messages() still returned only started, success, error and workflow_approval, so a template created without explicit messages exposed a messages dict with no changed key. The send path falls back through .get('changed', {}), so nothing broke, but the API response was inconsistent. --- awx/main/migrations/0209_notification_templates_changed.py | 4 ++-- awx/main/models/notifications.py | 2 +- awx/main/tests/functional/test_notifications.py | 2 +- .../shared/NotificationTemplateForm.test.js | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/awx/main/migrations/0209_notification_templates_changed.py b/awx/main/migrations/0209_notification_templates_changed.py index 21bf8505f..838a1301c 100644 --- a/awx/main/migrations/0209_notification_templates_changed.py +++ b/awx/main/migrations/0209_notification_templates_changed.py @@ -10,11 +10,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='jobtemplate', name='notification_templates_changed', - field=models.ManyToManyField(blank=True, related_name='jobtemplate_notification_templates_for_changed', to='main.notificationtemplate'), + field=models.ManyToManyField(blank=True, related_name='%(class)s_notification_templates_for_changed', to='main.notificationtemplate'), ), migrations.AddField( model_name='organization', name='notification_templates_changed', - field=models.ManyToManyField(blank=True, related_name='organization_notification_templates_for_changed', to='main.notificationtemplate'), + field=models.ManyToManyField(blank=True, related_name='%(class)s_notification_templates_for_changed', to='main.notificationtemplate'), ), ] diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 70eaf7c38..aa5cfce3c 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -73,7 +73,7 @@ class Meta: notification_configuration = prevent_search(models.JSONField(default=dict)) def default_messages(): - return {'started': None, 'success': None, 'error': None, 'workflow_approval': None} + return {'started': None, 'success': None, 'error': None, 'changed': None, 'workflow_approval': None} messages = models.JSONField(null=True, blank=True, default=default_messages, help_text=_('Optional custom messages for notification template.')) diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index cf93030ac..c635bc577 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -47,7 +47,7 @@ def test_basic_parameterization(get, post, user, organization): assert 'url' in response.data['notification_configuration'] assert 'headers' in response.data['notification_configuration'] assert 'messages' in response.data - assert response.data['messages'] == {'started': None, 'success': None, 'error': None, 'workflow_approval': None} + assert response.data['messages'] == {'started': None, 'success': None, 'error': None, 'changed': None, 'workflow_approval': None} @pytest.mark.django_db diff --git a/awx/ui/src/screens/NotificationTemplate/shared/NotificationTemplateForm.test.js b/awx/ui/src/screens/NotificationTemplate/shared/NotificationTemplateForm.test.js index d293d335c..46ced3364 100644 --- a/awx/ui/src/screens/NotificationTemplate/shared/NotificationTemplateForm.test.js +++ b/awx/ui/src/screens/NotificationTemplate/shared/NotificationTemplateForm.test.js @@ -66,6 +66,7 @@ const defaults = { started: messageDef, success: messageDef, error: messageDef, + changed: messageDef, workflow_approval: { approved: messageDef, denied: messageDef, From 5ac31330c8fc4b55cb596fc99cdf7c2dd94a3118 Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Sun, 30 Aug 2026 15:38:35 +0200 Subject: [PATCH 4/4] fix: renumber the changed notification migration to 0210 Main landed 0209_cleanup_dab_rbac_leftovers, so this one moves up and depends on it. 0210 is the only leaf again. The rename carries no schema change, so a database that already ran the file under its old number needs nothing beyond the row in django_migrations naming the new one. --- ...plates_changed.py => 0210_notification_templates_changed.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0209_notification_templates_changed.py => 0210_notification_templates_changed.py} (90%) diff --git a/awx/main/migrations/0209_notification_templates_changed.py b/awx/main/migrations/0210_notification_templates_changed.py similarity index 90% rename from awx/main/migrations/0209_notification_templates_changed.py rename to awx/main/migrations/0210_notification_templates_changed.py index 838a1301c..d9140ebce 100644 --- a/awx/main/migrations/0209_notification_templates_changed.py +++ b/awx/main/migrations/0210_notification_templates_changed.py @@ -3,7 +3,7 @@ class Migration(migrations.Migration): dependencies = [ - ('main', '0208_alter_job_skip_tags_alter_jobtemplate_skip_tags'), + ('main', '0209_cleanup_dab_rbac_leftovers'), ] operations = [