Compare commits

...

1 Commits

Author SHA1 Message Date
Quentin BEY 173da99f76 🐛(brevo) use django-lasuite for marketing management
When the user is updated their list are overwritten with the
new value: this removes lists from other products.
We switch to the common lib implementation which manages this.

Warning: Deployment will need an update...
2026-01-20 18:24:13 +01:00
9 changed files with 146 additions and 573 deletions
+1
View File
@@ -15,6 +15,7 @@ and this project adheres to
- ♿️(frontend) make carousel image decorative #871 - ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849 - ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879 - ♿️(frontend) improve background effect announcements #879
- 🐛(brevo) use django-lasuite for marketing management #885
### Fixed ### Fixed
+3 -6
View File
@@ -249,7 +249,7 @@ You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device.
These are the environmental options available on meet backend. These are the environmental options available on meet backend.
| Option | Description | default | | Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data | | DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] | | DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | | | DJANGO_SECRET_KEY | Secret key used for Django security | |
@@ -352,11 +352,8 @@ These are the environmental options available on meet backend.
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | | | SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | | | SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false | | SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService | | LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| BREVO_API_KEY | Brevo API key for marketing emails | | | LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby | | LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 | | LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 | | LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
+6 -27
View File
@@ -1,21 +1,14 @@
"""Authentication Backends for the Meet core app.""" """Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from core.models import User from core.models import User
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -61,24 +54,10 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod @staticmethod
def signup_to_marketing_email(email): def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow. """Pragmatic approach to newsletter signup during authentication flow."""
create_or_update_contact.delay(
Details: email=email,
1. Uses a very short timeout (1s) to prevent blocking the auth process attributes={"VISIO_SOURCE": ["SIGNIN"]},
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)
"""
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
) )
def get_existing_user(self, sub, email): def get_existing_user(self, sub, email):
-138
View File
@@ -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()
@@ -2,14 +2,14 @@
from unittest import mock from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.exceptions import SuspiciousOperation
import pytest import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models from core import models
from core.authentication.backends import OIDCAuthenticationBackend from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -498,8 +498,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called() mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service") @mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_marketing): def test_signup_to_marketing_email_success(mock_create_or_update_contact):
"""Test successful marketing signup.""" """Test successful marketing signup."""
email = "test@example.com" email = "test@example.com"
@@ -508,46 +508,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
OIDCAuthenticationBackend.signup_to_marketing_email(email) OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction # Verify service interaction
mock_service = mock_marketing.return_value mock_create_or_update_contact.assert_called_once_with(
mock_service.create_contact.assert_called_once() email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
@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")
@@ -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
+12 -16
View File
@@ -245,6 +245,8 @@ class Base(Configuration):
"django.contrib.staticfiles", "django.contrib.staticfiles",
# OIDC third party # OIDC third party
"mozilla_django_oidc", "mozilla_django_oidc",
# LaSuite common
"lasuite.marketing",
] ]
# Cache # Cache
@@ -644,24 +646,18 @@ class Base(Configuration):
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL", environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None, environ_prefix=None,
) )
MARKETING_SERVICE_CLASS = values.Value( LASUITE_MARKETING = {
"core.services.marketing.BrevoMarketingService", "BACKEND": values.Value(
environ_name="MARKETING_SERVICE_CLASS", "lasuite.marketing.backends.dummy.DummyBackend",
environ_name="LASUITE_MARKETING_BACKEND",
environ_prefix=None, environ_prefix=None,
) ),
BREVO_API_KEY = SecretFileValue( "PARAMETERS": values.DictValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None default={},
) environ_name="LASUITE_MARKETING_PARAMETERS",
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None, environ_prefix=None,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda ),
) }
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations # Lobby configurations
LOBBY_KEY_PREFIX = values.Value( LOBBY_KEY_PREFIX = values.Value(
+1 -1
View File
@@ -32,7 +32,7 @@ dependencies = [
"django-configurations==2.5.1", "django-configurations==2.5.1",
"django-cors-headers==4.9.0", "django-cors-headers==4.9.0",
"django-countries==8.0.0", "django-countries==8.0.0",
"django-lasuite[all]==0.0.19", "django-lasuite[all]==0.0.22",
"django-parler==2.3", "django-parler==2.3",
"redis==5.2.1", "redis==5.2.1",
"django-redis==6.0.0", "django-redis==6.0.0",
@@ -9,11 +9,6 @@ secrets:
field: password field: password
podVariable: OIDC_RP_CLIENT_SECRET podVariable: OIDC_RP_CLIENT_SECRET
clusterSecretStore: bitwarden-login-meet clusterSecretStore: bitwarden-login-meet
- name: brevoApiKey
itemId: 99107889-6124-4436-97cc-a5193f28443f
field: password
podVariable: BREVO_API_KEY
clusterSecretStore: bitwarden-login-meet
image: image:
repository: localhost:5001/meet-backend repository: localhost:5001/meet-backend
pullPolicy: Always pullPolicy: Always
@@ -87,11 +82,6 @@ backend:
SUMMARY_SERVICE_API_TOKEN: password SUMMARY_SERVICE_API_TOKEN: password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True
BREVO_API_KEY:
secretKeyRef:
name: backend
key: BREVO_API_KEY
BREVO_API_CONTACT_LIST_IDS: 8
ROOM_TELEPHONY_ENABLED: True ROOM_TELEPHONY_ENABLED: True
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem