From 7309df411589c3ef5b6cbc87f375a349efbd887b Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 18 Dec 2024 10:07:36 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20add=20MarketingSe?= =?UTF-8?q?rvice=20protocol=20and=20Brevo=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced a MarketingService protocol for typed marketing operations, allowing easy integration of alternative services. Implemented a Brevo wrapper following the protocol to decouple the codebase from the sdk. These implementations are simple and pragmatic. Feel free to refactor them. --- src/backend/core/services/__init__.py | 0 .../core/services/marketing_service.py | 134 +++++++++++++ src/backend/core/tests/services/__init__.py | 0 .../tests/services/test_marketing_service.py | 187 ++++++++++++++++++ src/backend/meet/settings.py | 17 ++ 5 files changed, 338 insertions(+) create mode 100644 src/backend/core/services/__init__.py create mode 100644 src/backend/core/services/marketing_service.py create mode 100644 src/backend/core/tests/services/__init__.py create mode 100644 src/backend/core/tests/services/test_marketing_service.py diff --git a/src/backend/core/services/__init__.py b/src/backend/core/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/services/marketing_service.py b/src/backend/core/services/marketing_service.py new file mode 100644 index 00000000..a8c8fc8f --- /dev/null +++ b/src/backend/core/services/marketing_service.py @@ -0,0 +1,134 @@ +"""Marketing service in charge of pushing data for marketing automation.""" + +import logging +from dataclasses import dataclass +from functools import lru_cache +from typing import Dict, List, Optional, Protocol + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.utils.module_loading import import_string + +import brevo_python + +logger = logging.getLogger(__name__) + + +class ContactCreationError(Exception): + """Raised when the contact creation fails.""" + + +@dataclass +class ContactData: + """Contact data for marketing service integration.""" + + email: str + attributes: Optional[Dict[str, str]] = None + list_ids: Optional[List[int]] = None + update_enabled: bool = True + + +class MarketingServiceProtocol(Protocol): + """Interface for marketing automation service integrations.""" + + def create_contact( + self, contact_data: ContactData, timeout: Optional[int] = None + ) -> dict: + """Create or update a contact. + + Args: + contact_data: Contact information and attributes + timeout: API request timeout in seconds + + Returns: + dict: Service response + + Raises: + ContactCreationError: If contact creation fails + """ + + +class BrevoMarketingService: + """Brevo marketing automation integration. + + Handles: + - Contact management and segmentation + - Marketing campaigns and automation + - Email communications + + Configuration via Django settings: + - BREVO_API_KEY: API authentication + - BREVO_API_CONTACT_LIST_IDS: Default contact lists + - BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes + """ + + def __init__(self): + """Initialize Brevo (ex-sendinblue) marketing service.""" + + if not settings.BREVO_API_KEY: + raise ImproperlyConfigured("Brevo API key is required") + + configuration = brevo_python.Configuration() + configuration.api_key["api-key"] = settings.BREVO_API_KEY + + self._api_client = brevo_python.ApiClient(configuration) + + def create_contact(self, contact_data: ContactData, timeout=None) -> dict: + """Create or update a Brevo contact. + + Args: + contact_data: Contact information and attributes + timeout: API request timeout in seconds + + Returns: + dict: Brevo API response + + Raises: + ContactCreationError: If contact creation fails + ImproperlyConfigured: If required settings are missing + + Note: + Contact attributes must be pre-configured in Brevo. + Changes to attributes can impact existing workflows. + """ + + if not settings.BREVO_API_CONTACT_LIST_IDS: + raise ImproperlyConfigured( + "Default Brevo List IDs must be configured in settings." + ) + + contact_api = brevo_python.ContactsApi(self._api_client) + + attributes = { + **settings.BREVO_API_CONTACT_ATTRIBUTES, + **(contact_data.attributes or {}), + } + + list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS + + contact = brevo_python.CreateContact( + email=contact_data.email, + attributes=attributes, + list_ids=list_ids, + update_enabled=contact_data.update_enabled, + ) + + api_configurations = {} + + if timeout is not None: + api_configurations["_request_timeout"] = timeout + + try: + response = contact_api.create_contact(contact, **api_configurations) + except brevo_python.rest.ApiException as err: + logger.exception("Failed to create contact in Brevo") + raise ContactCreationError("Failed to create contact in Brevo") from err + + return response + + +@lru_cache(maxsize=1) +def get_marketing_service() -> MarketingServiceProtocol: + """Return cached instance of configured marketing service.""" + marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS) + return marketing_service_cls() diff --git a/src/backend/core/tests/services/__init__.py b/src/backend/core/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/services/test_marketing_service.py b/src/backend/core/tests/services/test_marketing_service.py new file mode 100644 index 00000000..910fbae5 --- /dev/null +++ b/src/backend/core/tests/services/test_marketing_service.py @@ -0,0 +1,187 @@ +""" +Test marketing services. +""" + +# pylint: disable=W0621,W0613 + +from unittest import mock + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import brevo_python +import pytest + +from core.services.marketing_service import ( + BrevoMarketingService, + ContactCreationError, + ContactData, + get_marketing_service, +) + + +def test_init_missing_api_key(settings): + """Test initialization with missing API key.""" + settings.BREVO_API_KEY = None + with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"): + BrevoMarketingService() + + +def test_create_contact_missing_list_ids(settings): + """Test contact creation with missing list IDs.""" + + settings.BREVO_API_KEY = "test-api-key" + settings.BREVO_API_CONTACT_LIST_IDS = None + settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"} + + valid_contact_data = ContactData( + email="test@example.com", + attributes={"first_name": "Test"}, + list_ids=[1, 2], + update_enabled=True, + ) + + brevo_service = BrevoMarketingService() + + with pytest.raises( + ImproperlyConfigured, match="Default Brevo List IDs must be configured" + ): + brevo_service.create_contact(valid_contact_data) + + +@mock.patch("brevo_python.ContactsApi") +def test_create_contact_success(mock_contact_api): + """Test successful contact creation.""" + + mock_api = mock_contact_api.return_value + + settings.BREVO_API_KEY = "test-api-key" + settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4] + settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"} + + valid_contact_data = ContactData( + email="test@example.com", + attributes={"first_name": "Test"}, + list_ids=[1, 2], + update_enabled=True, + ) + + brevo_service = BrevoMarketingService() + + mock_api.create_contact.return_value = {"id": "test-id"} + response = brevo_service.create_contact(valid_contact_data) + + assert response == {"id": "test-id"} + + mock_api.create_contact.assert_called_once() + contact_arg = mock_api.create_contact.call_args[0][0] + assert contact_arg.email == "test@example.com" + assert contact_arg.attributes == { + **settings.BREVO_API_CONTACT_ATTRIBUTES, + **valid_contact_data.attributes, + } + assert set(contact_arg.list_ids) == {1, 2, 3, 4} + assert contact_arg.update_enabled is True + + +@mock.patch("brevo_python.ContactsApi") +def test_create_contact_with_timeout(mock_contact_api): + """Test contact creation with timeout.""" + + mock_api = mock_contact_api.return_value + + settings.BREVO_API_KEY = "test-api-key" + settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4] + settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"} + + valid_contact_data = ContactData( + email="test@example.com", + attributes={"first_name": "Test"}, + list_ids=[1, 2], + update_enabled=True, + ) + + brevo_service = BrevoMarketingService() + brevo_service.create_contact(valid_contact_data, timeout=30) + + mock_api.create_contact.assert_called_once() + assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30 + + +@mock.patch("brevo_python.ContactsApi") +def test_create_contact_api_error(mock_contact_api): + """Test contact creation API error handling.""" + + mock_api = mock_contact_api.return_value + + settings.BREVO_API_KEY = "test-api-key" + settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4] + settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"} + + valid_contact_data = ContactData( + email="test@example.com", + attributes={"first_name": "Test"}, + list_ids=[1, 2], + update_enabled=True, + ) + + brevo_service = BrevoMarketingService() + + mock_api.create_contact.side_effect = brevo_python.rest.ApiException() + + with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"): + brevo_service.create_contact(valid_contact_data) + + +@pytest.fixture +def clear_marketing_cache(): + """Clear marketing service cache between tests.""" + get_marketing_service.cache_clear() + yield + get_marketing_service.cache_clear() + + +def test_get_marketing_service_caching(clear_marketing_cache): + """Test marketing service caching behavior.""" + settings.BREVO_API_KEY = "test-api-key" + settings.MARKETING_SERVICE_CLASS = ( + "core.services.marketing_service.BrevoMarketingService" + ) + + service1 = get_marketing_service() + service2 = get_marketing_service() + + assert service1 is service2 + assert isinstance(service1, BrevoMarketingService) + + +def test_get_marketing_service_invalid_class(clear_marketing_cache): + """Test handling of invalid service class.""" + settings.MARKETING_SERVICE_CLASS = "invalid.service.path" + + with pytest.raises(ImportError): + get_marketing_service() + + +@mock.patch("core.services.marketing_service.import_string") +def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache): + """Test service class is instantiated only once.""" + + settings.BREVO_API_KEY = "test-api-key" + settings.MARKETING_SERVICE_CLASS = ( + "core.services.marketing_service.BrevoMarketingService" + ) + get_marketing_service.cache_clear() + + mock_service_cls = mock.Mock() + mock_service_instance = mock.Mock() + mock_service_cls.return_value = mock_service_instance + mock_import_string.return_value = mock_service_cls + + service1 = get_marketing_service() + service2 = get_marketing_service() + + mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS) + mock_service_cls.assert_called_once() + assert service1 is service2 + assert service1 is mock_service_instance diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index dfaadea5..6dd52171 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -457,6 +457,23 @@ class Base(Configuration): None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None ) + # Marketing and communication settings + MARKETING_SERVICE_CLASS = values.Value( + "core.services.marketing_service.BrevoMarketingService", + environ_name="MARKETING_SERVICE_CLASS", + environ_prefix=None, + ) + BREVO_API_KEY = values.Value( + None, environ_name="BREVO_API_KEY", environ_prefix=None + ) + BREVO_API_CONTACT_LIST_IDS = values.ListValue( + [], + environ_name="BREVO_API_CONTACT_LIST_IDS", + environ_prefix=None, + converter=lambda x: int(x), # pylint: disable=unnecessary-lambda + ) + BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True}) + # pylint: disable=invalid-name @property def ENVIRONMENT(self):