From ed4d527eb5378b9c4b5f297b3928d7b8556946f7 Mon Sep 17 00:00:00 2001 From: Ben Thomasson Date: Fri, 6 Mar 2026 16:00:10 -0500 Subject: [PATCH 1/2] fix: handle deleted workflow job in ancestor_job property When a workflow job is deleted while its child jobs still exist, `get_workflow_job()` returns None. The `ancestor_job` property previously called `.ancestor_job` on the None return value, causing an AttributeError 500 in the API (e.g. when serializing summary_fields). Add a null check so that jobs whose parent workflow has been deleted gracefully fall back to treating themselves as the ancestor. Fixes #16250 Co-Authored-By: Claude Opus 4.6 --- awx/main/models/unified_jobs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index f8682451a092..e84362f45a66 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1662,4 +1662,8 @@ def launched_by(self): @property def ancestor_job(self): - return self.get_workflow_job().ancestor_job if self.spawned_by_workflow else self + if self.spawned_by_workflow: + wj = self.get_workflow_job() + if wj is not None: + return wj.ancestor_job + return self From 4d64a71a8ed67e6ed6c47f3118901a1f67fb3c65 Mon Sep 17 00:00:00 2001 From: Ben Thomasson Date: Fri, 6 Mar 2026 16:03:48 -0500 Subject: [PATCH 2/2] test: add regression tests for ancestor_job with deleted workflow job Covers the crash from #16250 where ancestor_job called .ancestor_job on None when get_workflow_job() returned None for a deleted workflow. Co-Authored-By: Claude Opus 4.6 --- .../unit/models/test_unified_job_unit.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/awx/main/tests/unit/models/test_unified_job_unit.py b/awx/main/tests/unit/models/test_unified_job_unit.py index 2fa8807dff74..4e1030a7666c 100644 --- a/awx/main/tests/unit/models/test_unified_job_unit.py +++ b/awx/main/tests/unit/models/test_unified_job_unit.py @@ -21,6 +21,31 @@ def test_unified_job_workflow_attributes(): assert job.workflow_job_id == 1 +def test_ancestor_job_returns_self_when_not_workflow(): + job = UnifiedJob(id=1, name="job-1", launch_type="manual") + assert job.ancestor_job is job + + +def test_ancestor_job_returns_self_when_workflow_job_deleted(): + """Regression test for https://github.com/ansible/awx/issues/16250 + + When a workflow job is deleted while its child jobs still exist, + get_workflow_job() returns None. ancestor_job must not crash. + """ + job = UnifiedJob(id=1, name="job-1", launch_type="workflow") + with mock.patch.object(UnifiedJob, 'get_workflow_job', return_value=None): + assert job.ancestor_job is job + + +def test_ancestor_job_traverses_workflow(): + with mock.patch('django.db.ConnectionRouter.db_for_write'): + wj = WorkflowJob(pk=1, launch_type="manual") + child = UnifiedJob(id=2, name="child", launch_type="workflow") + child.unified_job_node = WorkflowJobNode(workflow_job=wj) + + assert child.ancestor_job is wj + + def test_organization_copy_to_jobs(): """ All unified job types should infer their organization from their template organization