diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 387277c5e9..81ba4fd50b 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1277,10 +1277,20 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin): def get_notification_friendly_name(self): return "Inventory Update" - def cancel(self): - res = super(InventoryUpdate, self).cancel() + def _build_job_explanation(self): + if not self.job_explanation: + return 'Previous Task Canceled: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % \ + (self.model_to_str(), self.name, self.id) + return None + + def get_dependent_jobs(self): + return Job.objects.filter(dependent_jobs__in=[self.id]) + + def cancel(self, job_explanation=None): + + res = super(InventoryUpdate, self).cancel(job_explanation=job_explanation) if res: - map(lambda x: x.cancel(), Job.objects.filter(dependent_jobs__in=[self.id])) + map(lambda x: x.cancel(job_explanation=self._build_job_explanation()), self.get_dependent_jobs()) return res diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 00a68c69ca..26969ffc32 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -633,10 +633,10 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin): Canceling a job also cancels the implicit project update with launch_type run. ''' - def cancel(self): - res = super(Job, self).cancel() + def cancel(self, job_explanation=None): + res = super(Job, self).cancel(job_explanation=job_explanation) if self.project_update: - self.project_update.cancel() + self.project_update.cancel(job_explanation=job_explanation) return res diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 2ccae7fdaf..880789aafe 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1025,7 +1025,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique if settings.DEBUG: raise - def cancel(self): + def cancel(self, job_explanation=None): if self.can_cancel: if not self.cancel_flag: self.cancel_flag = True @@ -1033,6 +1033,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique if self.status in ('pending', 'waiting', 'new'): self.status = 'canceled' cancel_fields.append('status') + if job_explanation is not None: + self.job_explanation = job_explanation + cancel_fields.append('job_explanation') self.save(update_fields=cancel_fields) self.websocket_emit_status("canceled") if settings.BROKER_URL.startswith('amqp://'): diff --git a/awx/main/tests/unit/models/__init__.py b/awx/main/tests/unit/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/unit/models/test_inventory.py b/awx/main/tests/unit/models/test_inventory.py new file mode 100644 index 0000000000..900881aa4c --- /dev/null +++ b/awx/main/tests/unit/models/test_inventory.py @@ -0,0 +1,38 @@ +import pytest +import mock +from awx.main.models import ( + UnifiedJob, + InventoryUpdate, + Job, +) + + +@pytest.fixture +def dependent_job(mocker): + j = Job(id=3, name='I_am_a_job') + j.cancel = mocker.MagicMock(return_value=True) + return [j] + + +def test_cancel(mocker, dependent_job): + with mock.patch.object(UnifiedJob, 'cancel', return_value=True) as parent_cancel: + iu = InventoryUpdate() + + iu.get_dependent_jobs = mocker.MagicMock(return_value=dependent_job) + iu.save = mocker.MagicMock() + build_job_explanation_mock = mocker.MagicMock() + iu._build_job_explanation = mocker.MagicMock(return_value=build_job_explanation_mock) + + iu.cancel() + + parent_cancel.assert_called_with(job_explanation=None) + dependent_job[0].cancel.assert_called_with(job_explanation=build_job_explanation_mock) + + +def test__build_job_explanation(): + iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update') + + job_explanation = iu._build_job_explanation() + + assert job_explanation == 'Previous Task Canceled: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % \ + ('inventory_update', 'I_am_an_Inventory_Update', 3) 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 af8833482a..256d6d0b03 100644 --- a/awx/main/tests/unit/models/test_unified_job_unit.py +++ b/awx/main/tests/unit/models/test_unified_job_unit.py @@ -1,3 +1,4 @@ +import pytest import mock from awx.main.models import ( @@ -14,3 +15,38 @@ def test_unified_job_workflow_attributes(): assert job.spawned_by_workflow is True assert job.workflow_job_id == 1 + + +@pytest.fixture +def unified_job(mocker): + mocker.patch.object(UnifiedJob, 'can_cancel', return_value=True) + j = UnifiedJob() + j.status = 'pending' + j.cancel_flag = None + j.save = mocker.MagicMock() + j.websocket_emit_status = mocker.MagicMock() + return j + + +def test_cancel(unified_job): + + unified_job.cancel() + + assert unified_job.cancel_flag is True + assert unified_job.status == 'canceled' + assert unified_job.job_explanation == '' + # Note: the websocket emit status check is just reflecting the state of the current code. + # Some more thought may want to go into only emitting canceled if/when the job record + # status is changed to canceled. Unlike, currently, where it's emitted unconditionally. + unified_job.websocket_emit_status.assert_called_with("canceled") + unified_job.save.assert_called_with(update_fields=['cancel_flag', 'status']) + + +def test_cancel_job_explanation(unified_job): + job_explanation = 'giggity giggity' + + unified_job.cancel(job_explanation=job_explanation) + + assert unified_job.job_explanation == job_explanation + unified_job.save.assert_called_with(update_fields=['cancel_flag', 'status', 'job_explanation']) +