mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9ac96d2b | |||
| 5428aec43e | |||
| a2408aebff | |||
| 3e3ca6a87b | |||
| 9830940d61 | |||
| dd7e3a7c44 | |||
| 1cf26eec19 | |||
| 83d4028e84 | |||
| ac65404ad6 | |||
| 4c0230d537 | |||
| 7309df4115 | |||
| 18b2dfc497 | |||
| 3282da7c56 | |||
| 7f8a6e8685 |
@@ -130,15 +130,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
del output["configuration"]
|
del output["configuration"]
|
||||||
|
|
||||||
if role is not None or instance.is_public:
|
if role is not None or instance.is_public:
|
||||||
slug = f"{instance.id!s}"
|
room_id = f"{instance.id!s}"
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": room_id,
|
||||||
"token": utils.generate_token(
|
"token": utils.generate_token(
|
||||||
room=slug, user=request.user, username=username
|
room=room_id, user=request.user, username=username
|
||||||
),
|
),
|
||||||
|
"passphrase": utils.get_cached_passphrase(room_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
output["is_administrable"] = is_admin
|
output["is_administrable"] = is_admin
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ from core.recording.worker.mediator import (
|
|||||||
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -210,6 +212,10 @@ class RoomViewSet(
|
|||||||
Allow unregistered rooms when activated.
|
Allow unregistered rooms when activated.
|
||||||
For unregistered rooms we only return a null id and the livekit room and token.
|
For unregistered rooms we only return a null id and the livekit room and token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# todo - determine whether encryption is needed store a shared secret in memory or in redis
|
||||||
|
# todo - check if a secret already exists, else create one.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
except Http404:
|
except Http404:
|
||||||
@@ -343,6 +349,36 @@ class RoomViewSet(
|
|||||||
{"message": f"Recording stopped for room {room.slug}."}
|
{"message": f"Recording stopped for room {room.slug}."}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="livekit-webhook",
|
||||||
|
permission_classes=[],
|
||||||
|
authentication_classes=[],
|
||||||
|
)
|
||||||
|
def handle_livekit_webhook(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Handle LiveKit webhook events."""
|
||||||
|
auth_token = request.headers.get("Authorization")
|
||||||
|
if not auth_token:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Missing LiveKit authentication token"},
|
||||||
|
status=drf_status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
token_verifier = livekit_api.TokenVerifier()
|
||||||
|
webhook_receiver = livekit_api.WebhookReceiver(token_verifier)
|
||||||
|
|
||||||
|
webhook_data = webhook_receiver.receive(request.body.decode("utf-8"), auth_token)
|
||||||
|
|
||||||
|
# Todo - livekit triggers a webhook for all events, see if we can restrict webhook to a limited number of events.
|
||||||
|
# Todo - handle Egress stopped / aborted events.
|
||||||
|
|
||||||
|
if webhook_data.event == "room_finished":
|
||||||
|
room_id = webhook_data.room.name
|
||||||
|
utils.clear_cache_passphrase(room_id)
|
||||||
|
|
||||||
|
return drf_response.Response({"message": f"Event processed"})
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessListModelMixin:
|
class ResourceAccessListModelMixin:
|
||||||
"""List mixin for resource access API."""
|
"""List mixin for resource access API."""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -10,6 +10,11 @@ from mozilla_django_oidc.auth import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
from core.services.marketing_service import (
|
||||||
|
ContactCreationError,
|
||||||
|
ContactData,
|
||||||
|
get_marketing_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||||
@@ -86,6 +91,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
password="!", # noqa: S106
|
password="!", # noqa: S106
|
||||||
**claims,
|
**claims,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||||
|
self.signup_to_marketing_email(email)
|
||||||
|
|
||||||
elif not user:
|
elif not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -96,6 +105,26 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def signup_to_marketing_email(email):
|
||||||
|
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||||
|
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
marketing_service = get_marketing_service()
|
||||||
|
contact_data = ContactData(
|
||||||
|
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||||
|
)
|
||||||
|
marketing_service.create_contact(contact_data, timeout=1)
|
||||||
|
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Fetch existing user by sub or email."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_user"
|
db_table = "meet_user"
|
||||||
|
ordering = ("-created_at",)
|
||||||
verbose_name = _("user")
|
verbose_name = _("user")
|
||||||
verbose_name_plural = _("users")
|
verbose_name_plural = _("users")
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ class ResourceAccess(BaseModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_resource_access"
|
db_table = "meet_resource_access"
|
||||||
|
ordering = ("-created_at",)
|
||||||
verbose_name = _("Resource access")
|
verbose_name = _("Resource access")
|
||||||
verbose_name_plural = _("Resource accesses")
|
verbose_name_plural = _("Resource accesses")
|
||||||
constraints = [
|
constraints = [
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"""Unit tests for the Authentication Backends."""
|
"""Unit tests for the Authentication Backends."""
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
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_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -412,3 +415,139 @@ def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
|||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
|
|
||||||
assert user.email == "john.doe@example.com"
|
assert user.email == "john.doe@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||||
|
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
|
||||||
|
"""Test marketing signup for new user with settings enabled."""
|
||||||
|
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
email = "test@example.com"
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user("test-token", None, None)
|
||||||
|
|
||||||
|
assert user.email == email
|
||||||
|
mock_signup.assert_called_once_with(email)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||||
|
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
|
||||||
|
"""Test no marketing signup for new user with settings disabled."""
|
||||||
|
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
email = "test@example.com"
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user("test-token", None, None)
|
||||||
|
|
||||||
|
assert user.email == email
|
||||||
|
mock_signup.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||||
|
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
|
||||||
|
"""Test no marketing signup for new user with settings by default disabled."""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
email = "test@example.com"
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user("test-token", None, None)
|
||||||
|
|
||||||
|
assert user.email == email
|
||||||
|
mock_signup.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"is_signup_enabled",
|
||||||
|
[True, False],
|
||||||
|
)
|
||||||
|
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||||
|
def test_marketing_signup_existing_user(
|
||||||
|
mock_signup, monkeypatch, settings, is_signup_enabled
|
||||||
|
):
|
||||||
|
"""Test no marketing signup for existing user regardless of settings."""
|
||||||
|
|
||||||
|
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="test@example.com")
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": db_user.sub, "email": db_user.email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user("test-token", None, None)
|
||||||
|
assert user == db_user
|
||||||
|
mock_signup.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||||
|
def test_signup_to_marketing_email_success(mock_marketing):
|
||||||
|
"""Test successful marketing signup."""
|
||||||
|
|
||||||
|
email = "test@example.com"
|
||||||
|
|
||||||
|
# Call the method
|
||||||
|
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_service.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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -66,7 +66,7 @@ def test_api_users_list_query_email():
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_ids = [user["id"] for user in response.json()["results"]]
|
user_ids = [user["id"] for user in response.json()["results"]]
|
||||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_retrieve_me_anonymous():
|
def test_api_users_retrieve_me_anonymous():
|
||||||
|
|||||||
@@ -14,6 +14,47 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit.api import AccessToken, VideoGrants
|
from livekit.api import AccessToken, VideoGrants
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_passphrase(length=26):
|
||||||
|
"""Generate a random passphrase using letters and digits"""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def build_room_passphrase_key(room_id: str) -> str:
|
||||||
|
"""Build cache key for room passphrase."""
|
||||||
|
return f"room_passphrase:{room_id}"
|
||||||
|
|
||||||
|
def get_cached_passphrase(room_id: str) -> str:
|
||||||
|
"""Get or generate encrypted passphrase for a room.
|
||||||
|
|
||||||
|
Retrieves existing passphrase from cache or generates,
|
||||||
|
encrypts and caches a new one if not found.
|
||||||
|
"""
|
||||||
|
cypher = Fernet(settings.PASSPHRASE_ENCRYPTION_KEY.encode())
|
||||||
|
cache_key = build_room_passphrase_key(room_id)
|
||||||
|
encrypted_passphrase = cache.get(cache_key)
|
||||||
|
|
||||||
|
if encrypted_passphrase is None:
|
||||||
|
passphrase = generate_random_passphrase()
|
||||||
|
encrypted_passphrase = cypher.encrypt(passphrase.encode()).decode()
|
||||||
|
cache.set(cache_key, encrypted_passphrase, timeout=86400) # 24 hours
|
||||||
|
return passphrase
|
||||||
|
|
||||||
|
return cypher.decrypt(encrypted_passphrase.encode()).decode()
|
||||||
|
|
||||||
|
def clear_room_passphrase(room_id: str) -> None:
|
||||||
|
"""Remove room passphrase from cache."""
|
||||||
|
cache.delete(build_room_passphrase_key(room_id))
|
||||||
|
|
||||||
|
|
||||||
def generate_color(identity: str) -> str:
|
def generate_color(identity: str) -> str:
|
||||||
"""Generates a consistent HSL color based on a given identity string.
|
"""Generates a consistent HSL color based on a given identity string.
|
||||||
|
|||||||
@@ -457,6 +457,34 @@ class Base(Configuration):
|
|||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Marketing and communication settings
|
||||||
|
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||||
|
False,
|
||||||
|
environ_name="SIGNUP_NEW_USERS_TO_NEWSLETTER",
|
||||||
|
environ_prefix=None,
|
||||||
|
help_text=(
|
||||||
|
"When enabled, new users are automatically added to mailing list "
|
||||||
|
"for product updates, marketing communications, and customized emails. "
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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})
|
||||||
|
|
||||||
|
PASSPHRASE_ENCRYPTION_KEY = values.Value(environ_name="PASSPHRASE_ENCRYPTION_KEY", environ_prefix=None)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.35.76",
|
"boto3==1.35.90",
|
||||||
"Brotli==1.1.0",
|
"Brotli==1.1.0",
|
||||||
|
"brevo-python==1.1.2",
|
||||||
"celery[redis]==5.4.0",
|
"celery[redis]==5.4.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.6.0",
|
"django-cors-headers==4.6.0",
|
||||||
@@ -56,8 +57,8 @@ dependencies = [
|
|||||||
"WeasyPrint>=60.2",
|
"WeasyPrint>=60.2",
|
||||||
"whitenoise==6.8.2",
|
"whitenoise==6.8.2",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.8.0",
|
"livekit-api==0.8.1",
|
||||||
"aiohttp==3.11.10",
|
"aiohttp==3.11.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -72,17 +73,17 @@ dev = [
|
|||||||
"drf-spectacular-sidecar==2024.12.1",
|
"drf-spectacular-sidecar==2024.12.1",
|
||||||
"freezegun==1.5.1",
|
"freezegun==1.5.1",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.30.0",
|
"ipython==8.31.0",
|
||||||
"pyfakefs==5.7.2",
|
"pyfakefs==5.7.3",
|
||||||
"pylint-django==2.6.1",
|
"pylint-django==2.6.1",
|
||||||
"pylint==3.3.2",
|
"pylint==3.3.3",
|
||||||
"pytest-cov==6.0.0",
|
"pytest-cov==6.0.0",
|
||||||
"pytest-django==4.9.0",
|
"pytest-django==4.9.0",
|
||||||
"pytest==8.3.4",
|
"pytest==8.3.4",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.6.1",
|
"pytest-xdist==3.6.1",
|
||||||
"responses==0.25.3",
|
"responses==0.25.3",
|
||||||
"ruff==0.8.2",
|
"ruff==0.8.4",
|
||||||
"types-requests==2.32.0.20241016",
|
"types-requests==2.32.0.20241016",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ApiRoom = {
|
|||||||
url: string
|
url: string
|
||||||
room: string
|
room: string
|
||||||
token: string
|
token: string
|
||||||
|
passphrase: string
|
||||||
}
|
}
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean
|
[key: string]: string | number | boolean
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||||
import { Room, RoomOptions } from 'livekit-client'
|
import {
|
||||||
|
Room,
|
||||||
|
RoomOptions,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
DeviceUnsupportedError,
|
||||||
|
} from 'livekit-client'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -17,6 +22,9 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
// todo - release worker when quitting the room, same for the key provider?
|
||||||
|
// todo - check, seems the demo app from livekit trigger the web worker twice because of re-rendering
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
userConfig,
|
userConfig,
|
||||||
@@ -63,12 +71,49 @@ export const Conference = ({
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const e2eeEnabled = true
|
||||||
|
|
||||||
|
const workerRef = useRef<Worker | null>(null)
|
||||||
|
const keyProvider = useRef<any | null>(null)
|
||||||
|
|
||||||
|
const getKeyProvider = () => {
|
||||||
|
if (!keyProvider.current && typeof window !== 'undefined') {
|
||||||
|
keyProvider.current = new ExternalE2EEKeyProvider()
|
||||||
|
}
|
||||||
|
return keyProvider.current
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWorker = () => {
|
||||||
|
if (!e2eeEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!workerRef.current && typeof window !== 'undefined') {
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return workerRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
const e2eePassphrase = data?.livekit?.passphrase
|
||||||
|
|
||||||
|
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false)
|
||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
|
const worker = getWorker()
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
|
||||||
|
// todo - explain why
|
||||||
|
const videoCodec = e2eeEnabled ? undefined : 'vp9'
|
||||||
|
const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
// todo - explain why
|
||||||
|
red: !e2eeEnabled,
|
||||||
|
videoCodec,
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -76,12 +121,35 @@ export const Conference = ({
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
|
e2ee,
|
||||||
}
|
}
|
||||||
// do not rely on the userConfig object directly as its reference may change on every render
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('enter', e2eePassphrase)
|
||||||
|
if (e2eePassphrase) {
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
keyProvider
|
||||||
|
.setKey(e2eePassphrase)
|
||||||
|
.then(() => {
|
||||||
|
room.setE2EEEnabled(true).catch((e) => {
|
||||||
|
if (e instanceof DeviceUnsupportedError) {
|
||||||
|
alert(
|
||||||
|
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`
|
||||||
|
)
|
||||||
|
console.error(e)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(() => setE2eeSetupComplete(true))
|
||||||
|
}
|
||||||
|
}, [room, e2eePassphrase])
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
@@ -102,6 +170,10 @@ export const Conference = ({
|
|||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEncryptionError = () => {
|
||||||
|
console.log('error')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false} footer={false}>
|
<Screen header={false} footer={false}>
|
||||||
@@ -109,13 +181,14 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={data?.livekit?.url}
|
serverUrl={data?.livekit?.url}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={true}
|
connect={e2eeSetupComplete}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
connectOptions={connectOptions}
|
connectOptions={connectOptions}
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
|
onEncryptionError={handleEncryptionError}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useIsSpeaking,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
isTrackReferencePinned,
|
isTrackReferencePinned,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ livekit:
|
|||||||
udp_port: 443
|
udp_port: 443
|
||||||
domain: livekit.127.0.0.1.nip.io
|
domain: livekit.127.0.0.1.nip.io
|
||||||
loadBalancerAnnotations: {}
|
loadBalancerAnnotations: {}
|
||||||
|
webhook:
|
||||||
|
api_key: devkey
|
||||||
|
urls:
|
||||||
|
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ backend:
|
|||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
|
PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo=
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.1.0",
|
"pydantic-settings>=2.1.0",
|
||||||
"celery==5.4.0",
|
"celery==5.4.0",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"minio==7.2.12",
|
"minio==7.2.13",
|
||||||
"openai==1.57.1",
|
"openai==1.58.1",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"sentry-sdk[fastapi, celery]==2.19.0",
|
"sentry-sdk[fastapi, celery]==2.19.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.8.2",
|
"ruff==0.8.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
Reference in New Issue
Block a user