From 76f0106566d83c52447ca61babd8abee2b66ff4c Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Mon, 26 Jan 2026 09:36:37 -0500 Subject: [PATCH 1/2] Create class CompositeBackend --- .../automation/utils/appnexus/base.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/contentcuration/automation/utils/appnexus/base.py b/contentcuration/automation/utils/appnexus/base.py index c242593feb..ca1be4a078 100644 --- a/contentcuration/automation/utils/appnexus/base.py +++ b/contentcuration/automation/utils/appnexus/base.py @@ -190,6 +190,32 @@ def create_backend(self) -> Backend: pass +class CompositeBackend: + def __init__(self, backends): + self.backends = backends + self.active_backend = None + + def connect(self, **kwargs): + """ + Loops through a list of backends in order to establish + which should be the active backend. + + """ + prefixes = [b.url_prefix for b in self.backends] + for backend in self.backends: + if backend.connect(**kwargs): + self.active_backend = backend + return True + raise AssertionError(f"Could not connect to any backend in list: {prefixes}") + + def make_request(self, request): + if self.active_backend: + return self.active_backend.make_request(request) + else: + self.connect() + return self.active_backend.make_request(request) + + class Adapter: """ Base class for adapters that interact with a backend interface. From 051faca9d14b427cb8b924b4dc6282395a698896 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 27 Jan 2026 09:08:12 -0500 Subject: [PATCH 2/2] Simplify CompositeBackend, update create_backedn to use Composite in non-prod settings, update tests --- .../automation/utils/appnexus/base.py | 29 ++++++++++--------- .../tests/utils/test_recommendations.py | 19 ++++++++++++ .../contentcuration/utils/recommendations.py | 16 +++++++--- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/contentcuration/automation/utils/appnexus/base.py b/contentcuration/automation/utils/appnexus/base.py index ca1be4a078..d39ec7f15c 100644 --- a/contentcuration/automation/utils/appnexus/base.py +++ b/contentcuration/automation/utils/appnexus/base.py @@ -191,29 +191,32 @@ def create_backend(self) -> Backend: class CompositeBackend: - def __init__(self, backends): - self.backends = backends - self.active_backend = None + def __init__(self, backend, prefixes): + self.backend = backend + self.prefixes = prefixes + self._connected = False def connect(self, **kwargs): """ - Loops through a list of backends in order to establish - which should be the active backend. + Loops through a list of prefixes in order to establish + which backend is available to connect. """ - prefixes = [b.url_prefix for b in self.backends] - for backend in self.backends: - if backend.connect(**kwargs): - self.active_backend = backend + for prefix in self.prefixes: + self.backend.url_prefix = prefix + if self.backend.connect(): + self._connected = True return True - raise AssertionError(f"Could not connect to any backend in list: {prefixes}") + raise AssertionError( + f"Could not connect to any backend in list: {self.prefixes}" + ) def make_request(self, request): - if self.active_backend: - return self.active_backend.make_request(request) + if self._connected: + return self.backend.make_request(request) else: self.connect() - return self.active_backend.make_request(request) + return self.backend.make_request(request) class Adapter: diff --git a/contentcuration/contentcuration/tests/utils/test_recommendations.py b/contentcuration/contentcuration/tests/utils/test_recommendations.py index c64e6ef489..4c90e55033 100644 --- a/contentcuration/contentcuration/tests/utils/test_recommendations.py +++ b/contentcuration/contentcuration/tests/utils/test_recommendations.py @@ -4,6 +4,7 @@ from automation.models import RecommendationsCache from automation.utils.appnexus import errors from automation.utils.appnexus.base import BackendResponse +from automation.utils.appnexus.base import CompositeBackend from django.test import TestCase from kolibri_public.models import ContentNode as PublicContentNode from mock import MagicMock @@ -550,6 +551,8 @@ def test_prepare_url_with_none(self): @patch("contentcuration.utils.recommendations.settings") def test_create_backend_with_url_no_scheme(self, mock_settings): + mock_settings.SITE_ID = "production" + mock_settings.PRODUCTION_SITE_ID = "production" mock_settings.CURRICULUM_AUTOMATION_API_URL = "api.example.com" backend = self.factory.create_backend() @@ -559,6 +562,8 @@ def test_create_backend_with_url_no_scheme(self, mock_settings): @patch("contentcuration.utils.recommendations.settings") def test_create_backend_with_url_with_scheme(self, mock_settings): + mock_settings.SITE_ID = "production" + mock_settings.PRODUCTION_SITE_ID = "production" mock_settings.CURRICULUM_AUTOMATION_API_URL = "https://api.example.com" backend = self.factory.create_backend() @@ -568,6 +573,8 @@ def test_create_backend_with_url_with_scheme(self, mock_settings): @patch("contentcuration.utils.recommendations.settings") def test_create_backend_with_empty_url(self, mock_settings): + mock_settings.SITE_ID = "production" + mock_settings.PRODUCTION_SITE_ID = "production" mock_settings.CURRICULUM_AUTOMATION_API_URL = "" backend = self.factory.create_backend() @@ -577,9 +584,21 @@ def test_create_backend_with_empty_url(self, mock_settings): @patch("contentcuration.utils.recommendations.settings") def test_create_backend_with_no_url(self, mock_settings): + mock_settings.SITE_ID = "production" + mock_settings.PRODUCTION_SITE_ID = "production" mock_settings.CURRICULUM_AUTOMATION_API_URL = None backend = self.factory.create_backend() self.assertIsInstance(backend, Recommendations) self.assertEqual(backend.base_url, None) self.assertEqual(backend.connect_endpoint, "/connect") + + @patch("contentcuration.utils.recommendations.settings") + def test_create_backend_to_unstable_url(self, mock_settings): + mock_settings.CURRICULUM_AUTOMATION_API_URL = "http://api.example.com:8080" + mock_settings.SITE_ID = "unstable" + mock_settings.PRODUCTION_SITE_ID = "production" + + backend = self.factory.create_backend() + self.assertIsInstance(backend, CompositeBackend) + self.assertEqual(backend.prefixes, ["unstable", "stable"]) diff --git a/contentcuration/contentcuration/utils/recommendations.py b/contentcuration/contentcuration/utils/recommendations.py index f24e88fe9f..c0559e8009 100644 --- a/contentcuration/contentcuration/utils/recommendations.py +++ b/contentcuration/contentcuration/utils/recommendations.py @@ -16,6 +16,7 @@ from automation.utils.appnexus.base import BackendFactory from automation.utils.appnexus.base import BackendRequest from automation.utils.appnexus.base import BackendResponse +from automation.utils.appnexus.base import CompositeBackend from django.conf import settings from django.db.models import Exists from django.db.models import F @@ -108,10 +109,17 @@ def _prepare_url(self, url): ) def create_backend(self) -> Backend: - backend = Recommendations() - backend.base_url = self._prepare_url(settings.CURRICULUM_AUTOMATION_API_URL) - backend.connect_endpoint = "/connect" - return backend + if settings.SITE_ID == settings.PRODUCTION_SITE_ID: + backend = Recommendations() + backend.base_url = self._prepare_url(settings.CURRICULUM_AUTOMATION_API_URL) + backend.connect_endpoint = "/connect" + return backend + else: + backend = Recommendations() + backend.base_url = self._prepare_url(settings.CURRICULUM_AUTOMATION_API_URL) + backend.connect_endpoint = "/connect" + + return CompositeBackend(backend, ["unstable", "stable"]) class RecommendationsAdapter(Adapter):