mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c86a47f736 | |||
| dd6b4512c8 | |||
| f82fd4bece | |||
| edab18d94a | |||
| 636c2168be | |||
| d54e9c2ad0 | |||
| 657712d7cb | |||
| d2bfbee389 | |||
| 9ba97fd14f | |||
| be0d0927d4 | |||
| 27dce44d40 | |||
| 78acaf395e | |||
| 4e5e648730 | |||
| 924fe95d94 | |||
| e3e33c7d0a | |||
| 16ee575ff8 | |||
| e42b083f20 |
@@ -8,6 +8,8 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.22.0] - 2026-07-03
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
|
||||
@@ -16,12 +18,19 @@ and this project adheres to
|
||||
- 🧱(helm) run clean files command as cronjob
|
||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||
- 🩹(frontend) enable screen share button in PiP #1458
|
||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(summary) generalized stt api call #1420
|
||||
- ♻️(env) refactor env variables handling
|
||||
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛂(backend) reject user access tokens on the API
|
||||
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
|
||||
|
||||
## [1.21.0] - 2026-06-15
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.5.13",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Pluggable analytics.
|
||||
|
||||
Usage anywhere in the codebase:
|
||||
|
||||
from core import analytics
|
||||
|
||||
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
|
||||
|
||||
The concrete backend is resolved lazily from Django settings, so swapping
|
||||
PostHog for anything else is a configuration change, not a code change.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from .base import AnalyticsBackend, NoOpAnalytics
|
||||
from .events import AnalyticsEvent
|
||||
|
||||
__all__ = [
|
||||
"get_analytics",
|
||||
"identify",
|
||||
"capture",
|
||||
"AnalyticsBackend",
|
||||
"AnalyticsEvent",
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_analytics() -> AnalyticsBackend:
|
||||
"""Instantiate the configured backend once per process."""
|
||||
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics()
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
|
||||
# Convenience module-level shortcuts
|
||||
|
||||
analytics_instance = get_analytics()
|
||||
|
||||
|
||||
def identify(user, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits with an identified user."""
|
||||
analytics_instance.identify(user, properties)
|
||||
|
||||
|
||||
def capture(
|
||||
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
analytics_instance.capture(user, event, properties)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Analytics backend protocol and default no-op implementation."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..models import User
|
||||
from .events import AnalyticsEvent
|
||||
|
||||
|
||||
class AnalyticsBackend(Protocol):
|
||||
"""
|
||||
Interface every analytics backend must implement.
|
||||
|
||||
Backends are instantiated once (singleton) with the kwargs declared in
|
||||
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
|
||||
|
||||
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
|
||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None: ...
|
||||
|
||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits (email, name, ...) with an identified user."""
|
||||
|
||||
def capture(
|
||||
self,
|
||||
user: User,
|
||||
event: AnalyticsEvent,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
|
||||
|
||||
class NoOpAnalytics:
|
||||
"""Default backend: silently discards everything."""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""No-op: accepts and ignores any backend settings kwargs."""
|
||||
|
||||
def identify(self, user: User, properties=None) -> None:
|
||||
"""No-op: discards identify calls."""
|
||||
|
||||
def capture(self, user, event, properties=None) -> None:
|
||||
"""No-op: discards captured events."""
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""No-op: nothing to flush."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Catalog of all analytics events emitted by the backend."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AnalyticsEvent(StrEnum):
|
||||
"""All trackable events. Values are the wire names sent to the provider."""
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""PostHog implementation of the analytics backend protocol."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
from ..models import User
|
||||
from .events import AnalyticsEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostHogAnalytics:
|
||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
host: str = "https://eu.i.posthog.com",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
||||
# The SDK batches and sends in a background thread by default,
|
||||
# so calls below never block the request/response cycle.
|
||||
self._client = Posthog(
|
||||
project_api_key=api_key,
|
||||
host=host,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _distinct_id(user: User) -> str | None:
|
||||
"""Return the PostHog distinct_id for a user, or None if anonymous."""
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
return str(user.pk)
|
||||
|
||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits (email, name, ...) with an identified user."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return
|
||||
try:
|
||||
self._client.set(
|
||||
distinct_id=distinct_id,
|
||||
properties=properties or {},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("PostHog identify failed")
|
||||
|
||||
def capture(
|
||||
self,
|
||||
user: User,
|
||||
event: AnalyticsEvent,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return
|
||||
try:
|
||||
self._client.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=str(event),
|
||||
properties=properties or {},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("PostHog capture failed for event %s", event)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
self._client.shutdown()
|
||||
@@ -35,7 +35,7 @@ from rest_framework import (
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import enums, models, utils
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -308,6 +308,16 @@ class RoomViewSet(
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"from_callback": bool(self.request.data.get("callback_id")),
|
||||
},
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Persist the room update, then sync metadata to LiveKit."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
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 (
|
||||
@@ -96,3 +97,17 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
|
||||
|
||||
class SessionAuthenticationWith401(SessionAuthentication):
|
||||
"""
|
||||
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
|
||||
header so unauthenticated requests get a 401 instead of a 403.
|
||||
|
||||
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
|
||||
native login popup. 'Session' is ignored by the browser's auth UI but is
|
||||
still truthy, so DRF keeps the status at 401.
|
||||
"""
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return "Session"
|
||||
|
||||
@@ -19,7 +19,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
@@ -194,10 +194,26 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
getattr(self.request.auth, "client_id", "unknown"),
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
@@ -164,6 +165,9 @@ class S3Parser(BaseS3Parser):
|
||||
if not filepath:
|
||||
raise ParsingEventDataError("Missing object key name")
|
||||
filetype, _ = mimetypes.guess_type(filepath)
|
||||
# Normalize raw S3-compatible object keys without re-encoding
|
||||
# already encoded AWS S3 notification keys.
|
||||
filepath = quote(filepath, safe="%+")
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Unit tests for PostHogAnalytics.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics.events import AnalyticsEvent
|
||||
from core.analytics.posthog import PostHogAnalytics
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ==============================
|
||||
# __init__
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
|
||||
"""Should forward api_key and host to the Posthog SDK constructor."""
|
||||
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
|
||||
|
||||
mock_posthog_cls.assert_called_once_with(
|
||||
project_api_key="my-key",
|
||||
host="https://custom.i.posthog.com",
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_defaults_to_eu_host(mock_posthog_cls):
|
||||
"""Should default host to the EU PostHog cloud when not specified."""
|
||||
PostHogAnalytics(api_key="my-key")
|
||||
|
||||
_, kwargs = mock_posthog_cls.call_args
|
||||
assert kwargs["host"] == "https://eu.i.posthog.com"
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
|
||||
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
|
||||
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
|
||||
|
||||
_, kwargs = mock_posthog_cls.call_args
|
||||
assert kwargs["debug"] is True
|
||||
assert kwargs["disabled"] is False
|
||||
|
||||
|
||||
# ==============================
|
||||
# _distinct_id
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
|
||||
"""Should return None when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(None) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should return None when user.is_authenticated is falsy."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(AnonymousUser()) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
|
||||
"""Should return None when the user object has no is_authenticated attribute at all."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(object()) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should return str(user.pk) for an authenticated user."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
assert backend._distinct_id(user) == str(user.pk)
|
||||
|
||||
|
||||
# ==============================
|
||||
# identify
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when the user is anonymous."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.identify(AnonymousUser(), {"email": "a@example.com"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_noop_for_none_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.identify(None, {"email": "a@example.com"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should call capture with event=$identify and properties wrapped in $set."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.identify(user, {"email": "a@example.com", "name": "A"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
properties={"email": "a@example.com", "name": "A"},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||
"""Should send an empty $set payload when properties is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.identify(user, None)
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
properties={},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
|
||||
"""Should log and not raise when the SDK call fails."""
|
||||
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
# Must not propagate.
|
||||
backend.identify(user, {"email": "a@example.com"})
|
||||
|
||||
|
||||
# ==============================
|
||||
# capture
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when the user is anonymous."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_noop_for_none_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should call capture with the distinct_id, event name, and properties."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
event="room_created",
|
||||
properties={"room_id": "room-1"},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
|
||||
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
_, kwargs = mock_posthog_cls.return_value.capture.call_args
|
||||
assert kwargs["event"] == "room_created"
|
||||
assert isinstance(
|
||||
kwargs["event"], str
|
||||
) # not AnalyticsEvent, not StrEnum subclass leaking through
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||
"""Should send an empty properties dict when properties is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
event="room_created",
|
||||
properties={},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
|
||||
"""Should log and not raise when the SDK call fails."""
|
||||
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
# Must not propagate.
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
|
||||
"""Should log which event failed, to aid debugging without crashing the caller."""
|
||||
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ==============================
|
||||
# shutdown
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_shutdown_flushes_the_client(mock_posthog_cls):
|
||||
"""Should delegate to the SDK's shutdown to flush pending events."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.shutdown()
|
||||
|
||||
mock_posthog_cls.return_value.shutdown.assert_called_once()
|
||||
@@ -360,6 +360,75 @@ def test_s3_parse_unrecognized_extension(s3_parser):
|
||||
s3_parser.parse(event_with_unknown_ext)
|
||||
|
||||
|
||||
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings/{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||
"""Test successful extraction of recording ID from S3 event."""
|
||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||
|
||||
@@ -323,8 +323,7 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -762,6 +761,14 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Analytics
|
||||
ANALYTICS_BACKEND = values.Value(
|
||||
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
|
||||
)
|
||||
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
|
||||
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False, # When enabled, new users are automatically added to mailing list.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -50,6 +50,7 @@ dependencies = [
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.16.1",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.13.0",
|
||||
|
||||
Generated
+39
-4
@@ -161,6 +161,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.4"
|
||||
@@ -569,6 +578,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
@@ -1066,14 +1084,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.4"
|
||||
version = "1.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1184,7 +1202,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1216,6 +1234,7 @@ dependencies = [
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1279,6 +1298,7 @@ requires-dist = [
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.31" },
|
||||
{ name = "posthog", specifier = "==7.16.1" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", specifier = "==2.13.0" },
|
||||
@@ -1528,6 +1548,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "7.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "distro" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pprintpp"
|
||||
version = "0.4.0"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ApiConfig {
|
||||
analytics?: {
|
||||
id: string
|
||||
host: string
|
||||
flags_api_host?: string
|
||||
}
|
||||
support?: {
|
||||
id: string
|
||||
|
||||
@@ -28,10 +28,16 @@ export const terminateAnalyticsSession = async () => {
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
flags_api_host?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
export const useAnalytics = ({
|
||||
id,
|
||||
host,
|
||||
flags_api_host,
|
||||
isDisabled,
|
||||
}: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
@@ -39,9 +45,13 @@ export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
if (!id || !host || isDisabled) return
|
||||
getPosthog().then((ph) => {
|
||||
if (ph.__loaded) return
|
||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
||||
ph.init(id, {
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
})
|
||||
}, [id, host, isDisabled])
|
||||
}, [id, host, flags_api_host, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
|
||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'pictureInPicture',
|
||||
})
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
|
||||
return (
|
||||
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
|
||||
<Container
|
||||
role="region"
|
||||
aria-label={t('stage')}
|
||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
@@ -61,9 +61,19 @@ export const FullScreenShareWarning = ({
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
const handleDismissWarning = useCallback(() => {
|
||||
screenSharePreferenceStore.enabled = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
handleDismissWarning()
|
||||
}
|
||||
},
|
||||
[handleDismissWarning]
|
||||
)
|
||||
|
||||
if (!shouldShowWarning) return null
|
||||
|
||||
@@ -98,6 +108,7 @@ export const FullScreenShareWarning = ({
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
role="alert"
|
||||
style={{
|
||||
color: 'white',
|
||||
flexBasis: '55%',
|
||||
@@ -117,11 +128,14 @@ export const FullScreenShareWarning = ({
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={async () => {
|
||||
await handleStopScreenShare()
|
||||
}}
|
||||
@@ -134,6 +148,7 @@ export const FullScreenShareWarning = ({
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={() => handleDismissWarning()}
|
||||
>
|
||||
{t('ignore')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.25
|
||||
version: 0.0.26
|
||||
|
||||
@@ -72,11 +72,11 @@ spec:
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.posthog.fullname" . }}-proxy
|
||||
name: {{ include "meet.posthog.fullname" $ }}-proxy
|
||||
port:
|
||||
number: {{ $.Values.posthog.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.posthog.fullname" . }}-proxy
|
||||
serviceName: {{ include "meet.posthog.fullname" $ }}-proxy
|
||||
servicePort: {{ $.Values.posthog.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.posthog.assetsService.customBackends }}
|
||||
|
||||
@@ -72,11 +72,11 @@ spec:
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.posthog.fullname" . }}-assets-proxy
|
||||
name: {{ include "meet.posthog.fullname" $ }}-assets-proxy
|
||||
port:
|
||||
number: {{ $.Values.posthog.assetsService.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.posthog.fullname" . }}-assets-proxy
|
||||
serviceName: {{ include "meet.posthog.fullname" $ }}-assets-proxy
|
||||
servicePort: {{ $.Values.posthog.assetsService.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.posthog.assetsService.customBackends }}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user