diff --git a/CHANGELOG.md b/CHANGELOG.md index 59dae864a..bc8537dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to - 🐛(summary) disable default S3 checksums for GCS-compatible storage - 🔒️(summary) redact meeting content from Sentry events - 🐛(frontend) hide tooltips until they have a computed placement +- 🐛(brevo) use django-lasuite for marketing management ## [1.33.0] - 2026-09-30 diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 8b97011ea..75b82786c 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -1,26 +1,19 @@ """Authentication Backends for the Meet core app.""" -import contextlib - from django.conf import settings from django.core.exceptions import ( - ImproperlyConfigured, SuspiciousOperation, ValidationError, ) from django.utils.translation import gettext_lazy as _ +from lasuite.marketing.tasks import create_or_update_contact from lasuite.oidc_login.backends import ( OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, ) from rest_framework.authentication import SessionAuthentication from core.models import User -from core.services.marketing import ( - ContactCreationError, - ContactData, - get_marketing_service, -) from core.validators import sub_validator @@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): @staticmethod def signup_to_marketing_email(email): - """Pragmatic approach to newsletter signup during authentication flow. + """Add the user to the newsletter list on sign-in. - Details: - 1. Uses a very short timeout (1s) to prevent blocking the auth process - 2. Silently fails if the marketing service is down/slow to prioritize user experience - 3. Trade-off: May miss some signups but ensures auth flow remains fast - - Note: For a more robust solution, consider using Async task processing (Celery/Django-Q) + Uses the team's standard Brevo integration, dispatching the contact + creation/update as an asynchronous task to keep authentication fast. """ - with contextlib.suppress( - ContactCreationError, ImproperlyConfigured, ImportError - ): - marketing_service = get_marketing_service() - contact_data = ContactData( - email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]} - ) - marketing_service.create_contact( - contact_data, timeout=settings.BREVO_API_TIMEOUT - ) + create_or_update_contact.delay( + email=email, + attributes={"VISIO_SOURCE": ["SIGNIN"]}, + ) def get_existing_user(self, sub, email): """Fetch existing user by sub or email.""" diff --git a/src/backend/core/services/marketing.py b/src/backend/core/services/marketing.py deleted file mode 100644 index 554e740c0..000000000 --- a/src/backend/core/services/marketing.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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 -import urllib3 - -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, - urllib3.exceptions.ReadTimeoutError, - ) as err: - logger.warning("Failed to create contact in Brevo", exc_info=True) - 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/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index 79beadaf5..55e9c0e78 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -2,14 +2,14 @@ from unittest import mock -from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation +from django.core.exceptions import SuspiciousOperation import pytest +from lasuite.marketing.tasks import create_or_update_contact from core import models from core.authentication.backends import OIDCAuthenticationBackend from core.factories import UserFactory -from core.services import marketing pytestmark = pytest.mark.django_db @@ -606,8 +606,8 @@ def test_marketing_signup_existing_user( mock_signup.assert_not_called() -@mock.patch("core.authentication.backends.get_marketing_service") -def test_signup_to_marketing_email_success(mock_marketing): +@mock.patch.object(create_or_update_contact, "delay") +def test_signup_to_marketing_email_success(mock_create_or_update_contact): """Test successful marketing signup.""" email = "test@example.com" @@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing): OIDCAuthenticationBackend.signup_to_marketing_email(email) # Verify service interaction - mock_service = mock_marketing.return_value - mock_service.create_contact.assert_called_once() - - -@pytest.mark.parametrize( - "error", - [ - ImportError, - ImproperlyConfigured, - ], -) -@mock.patch("core.authentication.backends.get_marketing_service") -def test_marketing_signup_handles_service_initialization_errors( - mock_marketing, error, settings -): - """Tests errors that occur when trying to get/initialize the marketing service.""" - settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True - - mock_marketing.side_effect = error - - # Should not raise any exception - OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com") - - -@pytest.mark.parametrize( - "error", - [ - marketing.ContactCreationError, - ImproperlyConfigured, - ImportError, - ], -) -@mock.patch("core.authentication.backends.get_marketing_service") -def test_marketing_signup_handles_contact_creation_errors( - mock_marketing, error, settings -): - """Tests errors that occur during the contact creation process.""" - - settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True - mock_marketing.return_value.create_contact.side_effect = error - - # Should not raise any exception - OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com") + mock_create_or_update_contact.assert_called_once_with( + email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]} + ) diff --git a/src/backend/core/tests/services/test_marketing.py b/src/backend/core/tests/services/test_marketing.py deleted file mode 100644 index 2b0686d14..000000000 --- a/src/backend/core/tests/services/test_marketing.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -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 -import urllib3 - -from core.services.marketing 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) - - -@mock.patch("brevo_python.ContactsApi") -def test_create_contact_timeout_error(mock_contact_api): - """Test contact creation timeout 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 = urllib3.exceptions.ReadTimeoutError( - pool=mock.Mock(), - url="https://api.brevo.com/v3/endpoint", - message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.", - ) - - 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.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.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.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 27d05ae10..f39a633bb 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -938,24 +938,18 @@ class Base(Configuration): environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL", environ_prefix=None, ) - MARKETING_SERVICE_CLASS = values.Value( - "core.services.marketing.BrevoMarketingService", - environ_name="MARKETING_SERVICE_CLASS", - environ_prefix=None, - ) - BREVO_API_KEY = SecretFileValue( - 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=int, - ) - BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True}) - BREVO_API_TIMEOUT = values.PositiveIntegerValue( - 1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None - ) + LASUITE_MARKETING = { + "BACKEND": values.Value( + "lasuite.marketing.backends.dummy.DummyBackend", + environ_name="LASUITE_MARKETING_BACKEND", + environ_prefix=None, + ), + "PARAMETERS": values.DictValue( + default={}, + environ_name="LASUITE_MARKETING_PARAMETERS", + environ_prefix=None, + ), + } # Lobby configurations PRESENCE_KEY_PREFIX = values.Value(