From 5188d05861e1fed476fe64c7472746899d64433c Mon Sep 17 00:00:00 2001 From: luisfelipec95 Date: Thu, 27 Aug 2026 23:04:19 -0500 Subject: [PATCH 1/3] feat: add validation to issue badge --- credentials/apps/badges/admin.py | 1 + .../migrations/0003_fulfillment_course_key.py | 18 +++ credentials/apps/badges/models.py | 9 +- .../apps/badges/processing/progression.py | 8 +- .../apps/badges/processing/restrictions.py | 118 ++++++++++++++++++ credentials/apps/badges/signals/handlers.py | 10 ++ 6 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 credentials/apps/badges/migrations/0003_fulfillment_course_key.py create mode 100644 credentials/apps/badges/processing/restrictions.py diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index 82dbf2663..7725b079e 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -136,6 +136,7 @@ class FulfillmentInline(admin.TabularInline): extra = 0 readonly_fields = [ "requirement", + "course_key", ] diff --git a/credentials/apps/badges/migrations/0003_fulfillment_course_key.py b/credentials/apps/badges/migrations/0003_fulfillment_course_key.py new file mode 100644 index 000000000..def427ff6 --- /dev/null +++ b/credentials/apps/badges/migrations/0003_fulfillment_course_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-09-06 17:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0002_accredibleapiconfig_accrediblebadge_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='fulfillment', + name='course_key', + field=models.CharField(blank=True, help_text='Course key where this requirement was fulfilled', max_length=255, null=True), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 5fd76577d..9792acb13 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -209,7 +209,7 @@ class BadgeRequirement(models.Model): def __str__(self): return f"BadgeRequirement:{self.id}:{self.template.uuid}" - def fulfill(self, username: str): + def fulfill(self, username: str, course_key: str = None): """ Marks itself as "done" for the user. @@ -220,7 +220,9 @@ def fulfill(self, username: str): """ template_id = self.template.id progress = BadgeProgress.for_user(username=username, template_id=template_id, create_if_absent=True) - fulfillment, created = Fulfillment.objects.get_or_create(progress=progress, requirement=self, blend=self.blend) + fulfillment, created = Fulfillment.objects.get_or_create( + progress=progress, requirement=self, blend=self.blend, course_key=course_key + ) if created: notify_requirement_fulfilled( @@ -622,6 +624,9 @@ class Fulfillment(models.Model): help_text=_("Group ID for the requirement."), verbose_name=_("group"), ) + course_key = models.CharField( + max_length=255, null=True, blank=True, help_text="Course key where this requirement was fulfilled" + ) class CredlyBadge(UserCredential): diff --git a/credentials/apps/badges/processing/progression.py b/credentials/apps/badges/processing/progression.py index 356d9bdf5..fcc31587c 100644 --- a/credentials/apps/badges/processing/progression.py +++ b/credentials/apps/badges/processing/progression.py @@ -30,6 +30,12 @@ def process_requirements(event_type, username, payload): logger.debug("BADGES: found %s requirements to process.", len(requirements)) + course_key = None + + if hasattr(payload, "course") and hasattr(payload.course, "ccx_course_key"): + course_key = str(payload.course.ccx_course_key) + logger.debug(f"BADGES: extracted course_key={course_key}") + for requirement in requirements: # remember: the badge template is already "done" @@ -46,4 +52,4 @@ def process_requirements(event_type, username, payload): # process: payload rules if requirement.apply_rules(asdict(payload)): - requirement.fulfill(username) + requirement.fulfill(username, course_key=course_key) diff --git a/credentials/apps/badges/processing/restrictions.py b/credentials/apps/badges/processing/restrictions.py new file mode 100644 index 000000000..afd107d2e --- /dev/null +++ b/credentials/apps/badges/processing/restrictions.py @@ -0,0 +1,118 @@ +"""Badge issuance restriction checks.""" + +import logging + +import requests +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist + +from credentials.apps.badges.models import CredlyBadgeTemplate +from credentials.apps.core.models import SiteConfiguration + +logger = logging.getLogger(__name__) + + +def is_badge_issuance_allowed(username, badge_template_id, progress): + """ + Check whether badge issuance is allowed for the completed badge progress. + + The check is disabled by default. When enabled, each fulfillment with a + course key is validated against the configured restrictions API. + + Badge issuance is blocked only when an exact course match explicitly + disables the configured badge flag. Missing data or API errors allow + issuance by default. + """ + if not getattr(settings, "BADGES_ENABLE_ISSUANCE_RESTRICTIONS", False): + return True + + if not progress: + return True + + try: + badge_template = CredlyBadgeTemplate.objects.get(id=badge_template_id) + site_configuration = SiteConfiguration.objects.get(site=badge_template.site) + api_client = site_configuration.api_client + except ObjectDoesNotExist: + logger.exception( + "Unable to load SiteConfiguration for badge template %s. Badge issuance will be allowed by default.", + badge_template_id, + ) + return True + + fulfillments = progress.fulfillment_set.all() + restrictions_api_url = getattr( + settings, + "BADGES_ISSUANCE_RESTRICTIONS_API_URL", + "", + ) + restriction_flag_name = getattr( + settings, + "BADGES_ISSUANCE_RESTRICTIONS_FLAG_NAME", + "", + ) + + for fulfillment in fulfillments: + if not fulfillment.course_key: + continue + + course_key = str(fulfillment.course_key) + + try: + response = api_client.get( + restrictions_api_url, + params={"ccx_id": course_key}, + timeout=5, + ) + except requests.RequestException: + logger.exception( + "Error checking badge issuance restrictions for course %s.", + course_key, + ) + continue + + if response.status_code != 200: + logger.warning( + "Restrictions API returned status %s for course %s.", + response.status_code, + course_key, + ) + continue + + try: + response_data = response.json() + except ValueError: + logger.warning( + "Restrictions API returned invalid JSON for course %s.", + course_key, + ) + continue + + results = response_data.get("results", []) + + matched_result = next( + (result for result in results if result.get("ccx_id") == course_key), + None, + ) + + if not matched_result: + logger.warning( + "No exact restriction result found for course %s. Badge issuance will be allowed by default.", + course_key, + ) + continue + + allow_badges = matched_result.get( + restriction_flag_name, + True, + ) + + if not allow_badges: + logger.info( + "Badge issuance blocked for user %s and course %s.", + username, + course_key, + ) + return False + + return True diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py index 644079c6d..054cb7776 100644 --- a/credentials/apps/badges/signals/handlers.py +++ b/credentials/apps/badges/signals/handlers.py @@ -12,6 +12,7 @@ from credentials.apps.badges.issuers import AccredibleBadgeTemplateIssuer, CredlyBadgeTemplateIssuer from credentials.apps.badges.models import AccredibleGroup, BadgeProgress, CredlyBadgeTemplate from credentials.apps.badges.processing.generic import process_event +from credentials.apps.badges.processing.restrictions import is_badge_issuance_allowed from credentials.apps.badges.signals import ( BADGE_PROGRESS_COMPLETE, BADGE_PROGRESS_INCOMPLETE, @@ -80,6 +81,15 @@ def handle_badge_completion(sender, username, badge_template_id, origin, **kwarg logger.debug("BADGES: progress is complete for %s on the %s", username, badge_template_id) if origin == CredlyBadgeTemplate.ORIGIN: + progress = BadgeProgress.for_user(username=username, template_id=badge_template_id) + + if not is_badge_issuance_allowed( + username=username, + badge_template_id=badge_template_id, + progress=progress, + ): + return + CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id) elif origin == AccredibleGroup.ORIGIN: AccredibleBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id) From 298748e92204b98bf9921223bdc2b897b21bc6d2 Mon Sep 17 00:00:00 2001 From: luisfelipec95 Date: Thu, 8 Oct 2026 15:57:27 -0500 Subject: [PATCH 2/3] docs: add Badge Issuance Restrictions documentation --- docs/badge_issuance_restrictions.rst | 129 +++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 130 insertions(+) create mode 100644 docs/badge_issuance_restrictions.rst diff --git a/docs/badge_issuance_restrictions.rst b/docs/badge_issuance_restrictions.rst new file mode 100644 index 000000000..d960cb45f --- /dev/null +++ b/docs/badge_issuance_restrictions.rst @@ -0,0 +1,129 @@ +Badge Issuance Restrictions +=========================== + +Credentials supports an optional external restriction check for Credly badge issuance. + +Overview +-------- + +When a learner completes all requirements for a Credly badge, Credentials normally +issues the badge immediately. Some deployments need an additional validation step +before badge issuance, based on business rules managed outside of Credentials. + +When badge issuance restrictions are enabled, Credentials checks an external API +before issuing the badge. The API is queried using the course context associated +with each fulfillment that contributed to badge completion. + +If the external API indicates that badge issuance is not allowed for a matching +course, Credentials does not issue the badge. + +This feature is optional and disabled by default. + +Configuration +------------- + +To enable badge issuance restrictions, configure the following settings: + +* ``BADGES_ENABLE_ISSUANCE_RESTRICTIONS`` + Enables the restriction check. + + Example:: + + BADGES_ENABLE_ISSUANCE_RESTRICTIONS = True + +* ``BADGES_ISSUANCE_RESTRICTIONS_API_URL`` + The external API endpoint used to validate whether badge issuance is allowed + for a fulfillment course. + + Example:: + + BADGES_ISSUANCE_RESTRICTIONS_API_URL = "https://example.com/api/v1/restrictions/" + +* ``BADGES_ISSUANCE_RESTRICTIONS_FLAG_NAME`` + The name of the response field that Credentials reads from the matched API + result to determine whether badge issuance is allowed. + + Example:: + + BADGES_ISSUANCE_RESTRICTIONS_FLAG_NAME = "allow_badges" + +Credentials uses the existing backend service OAuth client configuration for +authenticated inter-service requests: + +* ``BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL`` +* ``BACKEND_SERVICE_EDX_OAUTH2_KEY`` +* ``BACKEND_SERVICE_EDX_OAUTH2_SECRET`` + +Behavior +-------- + +When enabled, the restriction check behaves as follows: + +#. Badge completion is detected for a Credly badge. +#. Credentials loads the learner's badge progress. +#. Credentials inspects the related fulfillments. +#. For each fulfillment with a ``course_key``, Credentials calls the configured + external API. +#. Credentials searches the API response for an exact match on ``ccx_id``. +#. Credentials reads the configured restriction flag from the matching result. +#. If the flag indicates badge issuance is not allowed, the badge is not issued. +#. Otherwise, issuance proceeds normally. + +Default behavior +---------------- + +This feature is disabled by default. + +If the feature is disabled, Credentials behaves exactly as before and no external +restriction check is performed. + +If the feature is enabled but: + +* no matching restriction data is returned +* the external API returns an error +* the external API response cannot be parsed +* no fulfillment has a course key + +then Credentials allows badge issuance by default. + +Scope +----- + +This feature currently applies only to the Credly badge issuance flow. + +It does not change the Accredible badge issuance flow. + +Expected API response +--------------------- + +Example response: + +.. code-block:: json + + { + "results": [ + { + "ccx_id": "course-v1:ORG+COURSE+RUN+ccx@1", + "allow_badges": false + } + ] + } + +In this example: + +* ``ccx_id`` is compared against the fulfillment ``course_key`` +* ``allow_badges`` is an example restriction flag +* the actual flag name is defined by ``BADGES_ISSUANCE_RESTRICTIONS_FLAG_NAME`` + +Notes for operators +------------------- + +This feature is intended for deployments that need to integrate badge issuance with +externally managed business rules. + +Operators enabling this feature should ensure that: + +* the configured API is reachable from Credentials +* the backend service OAuth settings are valid +* the API returns stable course identifiers that match fulfillment course keys +* the configured restriction flag is present in the API response diff --git a/docs/index.rst b/docs/index.rst index 58fd2865c..d037496b4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,3 +28,4 @@ This repository contains the Open edX Credentials Service, used as the backend t lms_user_id program_completion_emails decisions + badge_issuance_restrictions From 3813e17c01b860e3ed92aa12d6eac16cd9300330 Mon Sep 17 00:00:00 2001 From: luisfelipec95 Date: Thu, 8 Oct 2026 16:06:31 -0500 Subject: [PATCH 3/3] test: add badge issuance restrictions coverage --- .../apps/badges/tests/test_handlers.py | 75 ++++++ .../apps/badges/tests/test_restrictions.py | 222 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 credentials/apps/badges/tests/test_handlers.py create mode 100644 credentials/apps/badges/tests/test_restrictions.py diff --git a/credentials/apps/badges/tests/test_handlers.py b/credentials/apps/badges/tests/test_handlers.py new file mode 100644 index 000000000..64fe3c844 --- /dev/null +++ b/credentials/apps/badges/tests/test_handlers.py @@ -0,0 +1,75 @@ +"""Tests for the badge completion signal handler and issuance restrictions.""" + +from unittest import mock + +from django.test import TestCase + +from credentials.apps.badges.models import AccredibleGroup, CredlyBadgeTemplate +from credentials.apps.badges.signals.signals import BADGE_PROGRESS_COMPLETE + +HANDLERS_MODULE = "credentials.apps.badges.signals.handlers" + + +class HandleBadgeCompletionRestrictionsTestCase(TestCase): + """ + Tests for `handle_badge_completion` with badge issuance restrictions. + """ + + def setUp(self): + super().setUp() + self.progress = mock.Mock() + + patchers = { + "progress_model": mock.patch(f"{HANDLERS_MODULE}.BadgeProgress"), + "is_allowed": mock.patch(f"{HANDLERS_MODULE}.is_badge_issuance_allowed"), + "credly_issuer": mock.patch(f"{HANDLERS_MODULE}.CredlyBadgeTemplateIssuer"), + "accredible_issuer": mock.patch(f"{HANDLERS_MODULE}.AccredibleBadgeTemplateIssuer"), + } + self.mocks = {} + for name, patcher in patchers.items(): + self.mocks[name] = patcher.start() + self.addCleanup(patcher.stop) + + self.mocks["progress_model"].for_user.return_value = self.progress + + def _send(self, origin): + BADGE_PROGRESS_COMPLETE.send( + sender=self, + username="test_user", + badge_template_id=1, + origin=origin, + ) + + def test_credly_badge_is_awarded_when_issuance_is_allowed(self): + self.mocks["is_allowed"].return_value = True + + self._send(CredlyBadgeTemplate.ORIGIN) + + self.mocks["progress_model"].for_user.assert_called_once_with(username="test_user", template_id=1) + self.mocks["is_allowed"].assert_called_once_with( + username="test_user", + badge_template_id=1, + progress=self.progress, + ) + self.mocks["credly_issuer"].return_value.award.assert_called_once_with( + username="test_user", + credential_id=1, + ) + + def test_credly_badge_is_not_awarded_when_issuance_is_restricted(self): + self.mocks["is_allowed"].return_value = False + + self._send(CredlyBadgeTemplate.ORIGIN) + + self.mocks["is_allowed"].assert_called_once() + self.mocks["credly_issuer"].return_value.award.assert_not_called() + + def test_accredible_badge_skips_restriction_check(self): + self._send(AccredibleGroup.ORIGIN) + + self.mocks["is_allowed"].assert_not_called() + self.mocks["accredible_issuer"].return_value.award.assert_called_once_with( + username="test_user", + credential_id=1, + ) + self.mocks["credly_issuer"].return_value.award.assert_not_called() diff --git a/credentials/apps/badges/tests/test_restrictions.py b/credentials/apps/badges/tests/test_restrictions.py new file mode 100644 index 000000000..6ab3575ac --- /dev/null +++ b/credentials/apps/badges/tests/test_restrictions.py @@ -0,0 +1,222 @@ +"""Tests for badge issuance restriction checks.""" + +from types import SimpleNamespace +from unittest import mock + +import requests +from django.core.exceptions import ObjectDoesNotExist +from django.test import TestCase, override_settings + +from credentials.apps.badges.processing.restrictions import is_badge_issuance_allowed + +RESTRICTIONS_MODULE = "credentials.apps.badges.processing.restrictions" +API_URL = "https://example.com/api/v1/restrictions/" +FLAG_NAME = "allow_badges" +CCX_ID = "ccx-v1:ORG+COURSE+RUN+ccx@1" + +ENABLED_SETTINGS = { + "BADGES_ENABLE_ISSUANCE_RESTRICTIONS": True, + "BADGES_ISSUANCE_RESTRICTIONS_API_URL": API_URL, + "BADGES_ISSUANCE_RESTRICTIONS_FLAG_NAME": FLAG_NAME, +} + + +def build_progress(*course_keys): + """ + Build a fake BadgeProgress exposing the given fulfillment course keys. + """ + fulfillments = [SimpleNamespace(course_key=course_key) for course_key in course_keys] + return SimpleNamespace(fulfillment_set=SimpleNamespace(all=lambda: fulfillments)) + + +def build_response(status_code=200, json_data=None, json_error=False): + """ + Build a fake API response. + """ + response = mock.Mock() + response.status_code = status_code + if json_error: + response.json.side_effect = ValueError("invalid json") + else: + response.json.return_value = json_data + return response + + +class IsBadgeIssuanceAllowedTestCase(TestCase): + """ + Tests for `is_badge_issuance_allowed`. + """ + + def setUp(self): + super().setUp() + self.api_client = mock.Mock() + + template_patcher = mock.patch(f"{RESTRICTIONS_MODULE}.CredlyBadgeTemplate") + site_config_patcher = mock.patch(f"{RESTRICTIONS_MODULE}.SiteConfiguration") + self.template_mock = template_patcher.start() + self.site_config_mock = site_config_patcher.start() + self.addCleanup(template_patcher.stop) + self.addCleanup(site_config_patcher.stop) + + self.template_mock.objects.get.return_value = SimpleNamespace(site="site") + self.site_config_mock.objects.get.return_value = SimpleNamespace(api_client=self.api_client) + + def _check(self, progress): + return is_badge_issuance_allowed( + username="test_user", + badge_template_id=1, + progress=progress, + ) + + def test_allowed_when_feature_is_not_configured(self): + """ + The feature is disabled by default: no setting means no external call. + """ + self.assertTrue(self._check(build_progress(CCX_ID))) + self.api_client.get.assert_not_called() + + @override_settings(BADGES_ENABLE_ISSUANCE_RESTRICTIONS=False) + def test_allowed_when_feature_is_disabled(self): + self.assertTrue(self._check(build_progress(CCX_ID))) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_progress_is_missing(self): + self.assertTrue(self._check(None)) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_badge_template_does_not_exist(self): + self.template_mock.objects.get.side_effect = ObjectDoesNotExist + + self.assertTrue(self._check(build_progress(CCX_ID))) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_site_configuration_does_not_exist(self): + self.site_config_mock.objects.get.side_effect = ObjectDoesNotExist + + self.assertTrue(self._check(build_progress(CCX_ID))) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_there_are_no_fulfillments(self): + self.assertTrue(self._check(build_progress())) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_fulfillments_without_course_key_are_skipped(self): + self.assertTrue(self._check(build_progress(None, ""))) + self.api_client.get.assert_not_called() + + @override_settings(**ENABLED_SETTINGS) + def test_api_is_called_with_course_key(self): + self.api_client.get.return_value = build_response( + json_data={"results": [{"ccx_id": CCX_ID, FLAG_NAME: True}]}, + ) + + self.assertTrue(self._check(build_progress(CCX_ID))) + self.api_client.get.assert_called_once_with( + API_URL, + params={"ccx_id": CCX_ID}, + timeout=5, + ) + + @override_settings(**ENABLED_SETTINGS) + def test_blocked_when_flag_is_false(self): + self.api_client.get.return_value = build_response( + json_data={"results": [{"ccx_id": CCX_ID, FLAG_NAME: False}]}, + ) + + self.assertFalse(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_blocked_if_any_fulfillment_is_restricted(self): + other_ccx_id = "ccx-v1:ORG+OTHER+RUN+ccx@2" + self.api_client.get.side_effect = [ + build_response(json_data={"results": [{"ccx_id": CCX_ID, FLAG_NAME: True}]}), + build_response(json_data={"results": [{"ccx_id": other_ccx_id, FLAG_NAME: False}]}), + ] + + self.assertFalse(self._check(build_progress(CCX_ID, other_ccx_id))) + self.assertEqual(self.api_client.get.call_count, 2) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_all_fulfillments_are_allowed(self): + other_ccx_id = "ccx-v1:ORG+OTHER+RUN+ccx@2" + self.api_client.get.side_effect = [ + build_response(json_data={"results": [{"ccx_id": CCX_ID, FLAG_NAME: True}]}), + build_response(json_data={"results": [{"ccx_id": other_ccx_id, FLAG_NAME: True}]}), + ] + + self.assertTrue(self._check(build_progress(CCX_ID, other_ccx_id))) + + @override_settings(**ENABLED_SETTINGS) + def test_only_exact_course_match_is_considered(self): + """ + Partial matches returned by the API must not block issuance. + """ + self.api_client.get.return_value = build_response( + json_data={"results": [{"ccx_id": f"{CCX_ID}0", FLAG_NAME: False}]}, + ) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_exact_match_is_used_among_multiple_results(self): + self.api_client.get.return_value = build_response( + json_data={ + "results": [ + {"ccx_id": f"{CCX_ID}0", FLAG_NAME: True}, + {"ccx_id": CCX_ID, FLAG_NAME: False}, + ] + }, + ) + + self.assertFalse(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_no_results_are_returned(self): + self.api_client.get.return_value = build_response(json_data={"results": []}) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_response_has_no_results_key(self): + self.api_client.get.return_value = build_response(json_data={}) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_flag_is_missing_from_result(self): + self.api_client.get.return_value = build_response(json_data={"results": [{"ccx_id": CCX_ID}]}) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_api_returns_error_status(self): + self.api_client.get.return_value = build_response(status_code=500) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_api_returns_invalid_json(self): + self.api_client.get.return_value = build_response(json_error=True) + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_allowed_when_api_request_fails(self): + self.api_client.get.side_effect = requests.ConnectionError("boom") + + self.assertTrue(self._check(build_progress(CCX_ID))) + + @override_settings(**ENABLED_SETTINGS) + def test_request_failure_does_not_stop_remaining_checks(self): + other_ccx_id = "ccx-v1:ORG+OTHER+RUN+ccx@2" + self.api_client.get.side_effect = [ + requests.Timeout("timeout"), + build_response(json_data={"results": [{"ccx_id": other_ccx_id, FLAG_NAME: False}]}), + ] + + self.assertFalse(self._check(build_progress(CCX_ID, other_ccx_id)))