diff --git a/src/olympia/abuse/models.py b/src/olympia/abuse/models.py index 85b191612490..2b3b53bf233a 100644 --- a/src/olympia/abuse/models.py +++ b/src/olympia/abuse/models.py @@ -1147,6 +1147,8 @@ def source(self): return DECISION_SOURCES.REVIEWER elif self.from_job_queue == CinderAddonHandledByLegal.queue: return DECISION_SOURCES.LEGAL + elif self.from_job_queue is None: + return DECISION_SOURCES.MANUAL else: return DECISION_SOURCES.TASKUS diff --git a/src/olympia/abuse/tests/assets/cinder_webhook_payloads/proactive_decision_from_cinder.json b/src/olympia/abuse/tests/assets/cinder_webhook_payloads/proactive_decision_from_cinder.json new file mode 100644 index 000000000000..89aa39a4e50f --- /dev/null +++ b/src/olympia/abuse/tests/assets/cinder_webhook_payloads/proactive_decision_from_cinder.json @@ -0,0 +1,71 @@ +{ + "event": "decision.created", + "payload": { + "appeals_resolved": [], + "enforcement_actions": [ + "amo-approve" + ], + "enforcement_actions_removed": [], + "entity": { + "attributes": { + "average_daily_users": 0, + "created": "2025-09-09T08:12:58.308105", + "guid": "{58d58fa6-2667-4695-a2d9-b05365899ba6}", + "id": "10001", + "last_updated": "2025-09-09T08:20:24.728686", + "name": "Livemarks_rollback2", + "promoted": "", + "slug": "livemarks_rollback2", + "summary": "Restores RSS live bookmark support to Firefox 64 and above." + }, + "entity_schema": "amo_addon" + }, + "notes": "some notes", + "point_updates": [], + "policies": [ + { + "enforcement_actions": [ + "amo-approve" + ], + "id": "bfc16995-6a3f-4fb7-abd5-58c4ecab44e6", + "is_illegal": false, + "is_non_violating": true, + "name": "Approve" + } + ], + "policies_removed": [], + "source": { + "decision": { + "id": "a51b39a6-e628-4cf6-b275-2a78bae497ee", + "metadata": {}, + "notes": "some notes", + "type": "manual", + "user": { + "email": "awilliamson@test.com", + "groups": [ + { + "name": "Admin" + }, + { + "name": "Everyone" + } + ], + "name": "Andrew Williamson" + } + }, + "user": { + "email": "awilliamson@test.com", + "groups": [ + { + "name": "Admin" + }, + { + "name": "Everyone" + } + ], + "name": "Andrew Williamson" + } + }, + "timestamp": "2026-05-28T12:01:13.403410+00:00" + } +} \ No newline at end of file diff --git a/src/olympia/abuse/tests/test_models.py b/src/olympia/abuse/tests/test_models.py index c533b202643c..8f04142700a2 100644 --- a/src/olympia/abuse/tests/test_models.py +++ b/src/olympia/abuse/tests/test_models.py @@ -2503,7 +2503,7 @@ def test_souce(self): decision = ContentDecision.objects.create( action=DECISION_ACTIONS.AMO_DISABLE_ADDON, addon=addon ) - assert decision.source == DECISION_SOURCES.TASKUS + assert decision.source == DECISION_SOURCES.MANUAL decision.update(reviewer_user=self.task_user) assert decision.source == DECISION_SOURCES.AUTOMATION diff --git a/src/olympia/abuse/tests/test_views.py b/src/olympia/abuse/tests/test_views.py index 95cf0ba1478d..1235099d4d7f 100644 --- a/src/olympia/abuse/tests/test_views.py +++ b/src/olympia/abuse/tests/test_views.py @@ -1742,8 +1742,33 @@ def test_reviewer_tools_resolved_decision(self): } def test_proactive_decision_from_cinder(self): + data = self.get_data(filename='proactive_decision_from_cinder.json') + addon = addon_factory( + id=10001, + created=datetime.fromisoformat( + data['payload']['entity']['attributes']['created'] + ), + ) + self._setup_reports() + req = self.get_request(data=data) + with mock.patch.object(CinderJob, 'create_and_execute_decision') as create_mock: + response = cinder_webhook(req) + create_mock.assert_called() + create_mock.assert_called_with( + None, + target=addon, + decision_cinder_id=data['payload']['source']['decision']['id'], + decision_actions=[DECISION_ACTIONS.AMO_APPROVE.value], + decision_notes='some notes', + policy_ids=[data['payload']['policies'][0]['id']], + job_queue=None, + ) + assert response.status_code == 201 + assert response.data == {'amo': {'received': True, 'handled': True}} + + def test_unsupported_decision_from_cinder(self): data = self.get_data(filename='proactive_decision_from_amo.json') - data['payload']['source']['decision']['type'] = 'queue_review' + data['payload']['source']['decision']['type'] = 'something' self._setup_reports() req = self.get_request(data=data) with mock.patch.object(CinderJob, 'create_and_execute_decision') as create_mock: @@ -1754,7 +1779,7 @@ def test_proactive_decision_from_cinder(self): 'amo': { 'received': True, 'handled': False, - 'not_handled_reason': 'Unsupported Manual decision', + 'not_handled_reason': 'Unsupported decision', } } diff --git a/src/olympia/abuse/views.py b/src/olympia/abuse/views.py index c5bf765b5127..25b86e178ecc 100644 --- a/src/olympia/abuse/views.py +++ b/src/olympia/abuse/views.py @@ -193,7 +193,8 @@ def reportable(self): def get_job_from_payload(payload): - if job_id := payload.get('source', {}).get('job', {}).get('id'): + source = payload.get('source', {}) + if job_id := source.get('job', {}).get('id'): try: return CinderJob.objects.get(job_id=job_id) except CinderJob.DoesNotExist: @@ -208,11 +209,12 @@ def get_job_from_payload(payload): raise CinderWebhookMissingIdError('No matching decision id found') from exc return prev_decision.cinder_job + if source.get('decision', {}).get('type') == 'manual': + log.debug('Cinder webhook decision is a manual decision, no job to link to.') + return None else: - # We may support this one day, but we currently don't support manual decisions - # that originated in Cinder - log.debug('Cinder webhook decision for cinder proactive decision skipped.') - raise CinderWebhookError('Unsupported Manual decision') + log.debug('Cinder webhook decision for unsupported scenario skipped.') + raise CinderWebhookError('Unsupported decision') def get_target_from_payload_entity(entity): diff --git a/src/olympia/constants/abuse.py b/src/olympia/constants/abuse.py index ff6efeccf504..2019cd5fab79 100644 --- a/src/olympia/constants/abuse.py +++ b/src/olympia/constants/abuse.py @@ -357,3 +357,4 @@ class DECISION_SOURCES(StrEnum): REVIEWER = 'AMO Reviewer' LEGAL = 'Legal' TASKUS = 'TaskUs' + MANUAL = 'Manual' diff --git a/src/olympia/reviewers/tests/test_views.py b/src/olympia/reviewers/tests/test_views.py index e35d12afe69e..cfb35a5a1c44 100644 --- a/src/olympia/reviewers/tests/test_views.py +++ b/src/olympia/reviewers/tests/test_views.py @@ -8121,10 +8121,13 @@ def setUp(self): self.url = reverse('reviewers.queue_decisions') self.addon_decision = ContentDecision.objects.create( - action=DECISION_ACTIONS.AMO_DISABLE_ADDON, addon=addon_factory() + action=DECISION_ACTIONS.AMO_DISABLE_ADDON, + addon=addon_factory(), + from_job_queue='some-extension-queue', ) self.user_decision = ContentDecision.objects.create( - action=DECISION_ACTIONS.AMO_BAN_USER, user=user_factory() + action=DECISION_ACTIONS.AMO_BAN_USER, + user=user_factory(), ) self.collection_decision = ContentDecision.objects.create( action=DECISION_ACTIONS.AMO_DELETE_COLLECTION, @@ -8211,6 +8214,7 @@ def setUp(self): metadata={ ContentDecision.POLICY_DYNAMIC_VALUES: {policy.uuid: {'FOO': 'baa'}} }, + from_job_queue='some-cinder-queue', ) self.version = self.decision.addon.current_version self.decision.target_versions.set([self.version]) @@ -8427,3 +8431,13 @@ def test_cant_submit_a_resolved_decision(self): field=None, errors=['Not currently held for 2nd level approval'], ) + + def test_manual_source(self): + self.decision.update(from_job_queue=None) + response = self.client.get(self.url) + assert response.status_code == 200 + doc = pq(response.content)('.entity-type-Extension') + + assert f'Extension Decision for {self.decision.addon.name}' in doc.html() + assert doc('h2').attr('class') == 'held-item MANUAL' + assert 'Manual' == doc('.decision-source td').text()