Compare commits

...

4 Commits

Author SHA1 Message Date
Roee Sivan eb422309b9 ✨(backend) allow static feature flags without an analytics backend
Feature flags are read from the analytics backend. Without one,
NoOpAnalytics returned no flag, so "summary-enabled" was always off and
a self-hosted instance could not get automatic summaries without PostHog.

NoOpAnalytics now accepts a static "feature_flags" mapping through the
existing ANALYTICS_BACKEND_SETTINGS, and get_analytics() passes the
settings to it. Nothing changes when no flag is declared.

Fixes #1610
2026-10-14 00:19:55 +02:00
leo 4854fc0c13 🔒️(devex) bind ports in compose.yaml to localhost
Some containers were being exposed to local networks. Bind them
to localhost, to tighten security. Don't apply this binding to
LiveKit (exposed on 7880), as we access it using 127.0.0.1.nip.io:7880.
2026-10-13 18:21:04 +02:00
lebaudantoine d930fa1ba6 ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
Case-insensitive user lookups during token issuance currently take
around 100 ms in production, accounting for roughly a third of the
total 300 ms token request trace.

Add an `UPPER(email)` index on the user table so these lookups can
use the index instead of scanning, cutting the dominant cost on
the token issuance path.
2026-10-13 15:20:04 +02:00
lebaudantoine 6f1414b34e 🔖(minor) bump release to 1.35.0 2026-10-13 09:34:46 +02:00
22 changed files with 233 additions and 31 deletions
+7
View File
@@ -10,9 +10,16 @@ and this project adheres to
### Added ### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding - ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds - ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task - ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
### Changed ### Changed
+13 -13
View File
@@ -5,17 +5,17 @@ services:
env_file: env_file:
- env.d/development/postgresql - env.d/development/postgresql
ports: ports:
- "15432:5432" - "127.0.0.1:15432:5432"
redis: redis:
image: redis:5 image: redis:5
ports: ports:
- "6379:6379" - "127.0.0.1:6379:6379"
mailcatcher: mailcatcher:
image: sj26/mailcatcher:latest image: sj26/mailcatcher:latest
ports: ports:
- "1081:1080" - "127.0.0.1:1081:1080"
garage: garage:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
@@ -71,7 +71,7 @@ services:
- env.d/development/common - env.d/development/common
- env.d/development/postgresql - env.d/development/postgresql
ports: ports:
- "8071:8000" - "127.0.0.1:8071:8000"
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
@@ -137,7 +137,7 @@ services:
nginx: nginx:
image: nginx:1.25 image: nginx:1.25
ports: ports:
- "8083:8083" - "127.0.0.1:8083:8083"
volumes: volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on: depends_on:
@@ -159,7 +159,7 @@ services:
VITE_APP_TITLE: "LaSuite Meet" VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development image: meet:frontend-development
ports: ports:
- "3000:8080" - "127.0.0.1:3000:8080"
dockerize: dockerize:
image: jwilder/dockerize image: jwilder/dockerize
@@ -185,7 +185,7 @@ services:
kc_postgresql: kc_postgresql:
image: postgres:14.3 image: postgres:14.3
ports: ports:
- "5433:5432" - "127.0.0.1:5433:5432"
env_file: env_file:
- env.d/development/kc_postgresql - env.d/development/kc_postgresql
@@ -213,7 +213,7 @@ services:
KC_DB_SCHEMA: public KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true' PROXY_ADDRESS_FORWARDING: 'true'
ports: ports:
- "8080:8080" - "127.0.0.1:8080:8080"
depends_on: depends_on:
- kc_postgresql - kc_postgresql
@@ -222,10 +222,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports: ports:
- "7880:7880" - "7880:7880"
- "7881:7881" - "127.0.0.1:7881:7881"
- "7882:7882/udp" - "127.0.0.1:7882:7882/udp"
- "3478:3478/udp" - "127.0.0.1:3478:3478/udp"
- "30000-30100:30000-30100/udp" - "127.0.0.1:30000-30100:30000-30100/udp"
volumes: volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml - ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on: depends_on:
@@ -283,7 +283,7 @@ services:
env_file: env_file:
- env.d/development/summary - env.d/development/summary
ports: ports:
- "8001:8000" - "127.0.0.1:8001:8000"
volumes: volumes:
- ./src/summary:/app - ./src/summary:/app
depends_on: depends_on:
+1 -1
View File
@@ -45,4 +45,4 @@ services:
# The local proxy listens on 8080 and collides with Keycloak's published admin port. # The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak: keycloak:
ports: !override ports: !override
- "8081:8080" - "127.0.0.1:8081:8080"
+20
View File
@@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png" DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
``` ```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall ## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service. If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+2
View File
@@ -416,6 +416,8 @@ These are the environmental options available on meet backend.
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | | | SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| 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 | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| 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 | | MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | | | BREVO_API_KEY | Brevo API key for marketing emails | |
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.34.0" version = "1.35.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.7.0", "livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.34.0" version = "1.35.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {} options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path: if not dotted_path:
return NoOpAnalytics() return NoOpAnalytics(**options)
backend_class = import_string(dotted_path) backend_class = import_string(dotted_path)
return backend_class(**options) return backend_class(**options)
+24 -1
View File
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend): class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything.""" """
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
def identify(self, user: User, properties=None) -> None: def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls.""" """No-op: discards identify calls."""
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None: def shutdown(self) -> None:
"""No-op: nothing to flush.""" """No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0026_alter_application_scopes'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
+3
View File
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null", name="unique_email_when_sub_is_null",
) )
] ]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self): def __str__(self):
return self.email or self.admin_email or str(self.id) return self.email or self.admin_email or str(self.id)
@@ -0,0 +1,124 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.34.0" version = "1.35.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.34.0" version = "1.35.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.34.0", "version": "1.35.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0", "@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.34.0", "version": "1.35.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.34.0" version = "1.35.0"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]] [[package]]
name = "summary" name = "summary"
version = "1.34.0" version = "1.35.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },