mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-15 09:23:08 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b6b7e213 | |||
| eb422309b9 | |||
| 4854fc0c13 | |||
| d930fa1ba6 | |||
| 6f1414b34e |
@@ -10,9 +10,17 @@ and this project adheres to
|
||||
|
||||
### 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
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ⚡️(backend) send recording notification emails from a Celery task
|
||||
- ✨(backend) allow static feature flags without an analytics backend #1809
|
||||
- ⚡️(backend) add an opt-in psycopg connection pool
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+13
-13
@@ -5,17 +5,17 @@ services:
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
- "127.0.0.1:15432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:6379:6379"
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
- "127.0.0.1:1081:1080"
|
||||
|
||||
garage:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
- "127.0.0.1:8071:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
- "127.0.0.1:8083:8083"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -159,7 +159,7 @@ services:
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "3000:8080"
|
||||
- "127.0.0.1:3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -185,7 +185,7 @@ services:
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "127.0.0.1:5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
@@ -222,10 +222,10 @@ services:
|
||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "3478:3478/udp"
|
||||
- "30000-30100:30000-30100/udp"
|
||||
- "127.0.0.1:7881:7881"
|
||||
- "127.0.0.1:7882:7882/udp"
|
||||
- "127.0.0.1:3478:3478/udp"
|
||||
- "127.0.0.1:30000-30100:30000-30100/udp"
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||
depends_on:
|
||||
@@ -283,7 +283,7 @@ services:
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
ports:
|
||||
- "8001:8000"
|
||||
- "127.0.0.1:8001:8000"
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
|
||||
@@ -45,4 +45,4 @@ services:
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "8081:8080"
|
||||
- "127.0.0.1:8081:8080"
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -318,12 +318,17 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| DB_PSYCOPG_POOL_ENABLED | Enable the psycopg connection pool | false |
|
||||
| DB_PSYCOPG_POOL_MIN_SIZE | Connections each pool keeps open | 1 |
|
||||
| DB_PSYCOPG_POOL_MAX_SIZE | Maximum connections per pool | 3 |
|
||||
| DB_PSYCOPG_POOL_TIMEOUT | Seconds a request waits for a free pool connection before failing | 30 |
|
||||
| DB_PSYCOPG_POOL_MAX_IDLE | Seconds an idle connection above the minimum stays open | 600 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
@@ -416,6 +421,8 @@ These are the environmental options available on meet backend.
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| 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 |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
|
||||
@@ -8,4 +8,5 @@ DB_HOST=postgresql
|
||||
DB_NAME=meet
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
DB_PSYCOPG_POOL_ENABLED=True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics()
|
||||
return NoOpAnalytics(**options)
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
|
||||
|
||||
|
||||
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:
|
||||
"""No-op: discards identify calls."""
|
||||
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -361,18 +362,21 @@ def test_api_files_create_file_race_condition():
|
||||
"""
|
||||
|
||||
def create_item(title):
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": title,
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
try:
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": title,
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_item, "my item 1")
|
||||
|
||||
@@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
@@ -295,9 +296,12 @@ def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
|
||||
|
||||
def call_upload_ended():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
try:
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Unit tests for the opt-in psycopg connection pool settings."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import pytest
|
||||
|
||||
from meet import settings
|
||||
|
||||
|
||||
def make_configuration(**overrides):
|
||||
"""Build a throwaway configuration with plain values and its own DATABASES."""
|
||||
attributes = {
|
||||
"DATABASES": {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"OPTIONS": {"sslmode": "require"},
|
||||
}
|
||||
},
|
||||
"DB_PSYCOPG_POOL_ENABLED": True,
|
||||
"DB_PSYCOPG_POOL_MIN_SIZE": 1,
|
||||
"DB_PSYCOPG_POOL_MAX_SIZE": 3,
|
||||
"DB_PSYCOPG_POOL_TIMEOUT": 30.0,
|
||||
"DB_PSYCOPG_POOL_MAX_IDLE": 600.0,
|
||||
**overrides,
|
||||
}
|
||||
return type("PoolConfiguration", (settings.Test,), attributes)
|
||||
|
||||
|
||||
def test_database_pool_disabled_leaves_options_untouched():
|
||||
"""Without the flag, no connection pool is configured."""
|
||||
configuration = make_configuration(DB_PSYCOPG_POOL_ENABLED=False)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"] == {"sslmode": "require"}
|
||||
assert "CONN_HEALTH_CHECKS" not in configuration.DATABASES["default"]
|
||||
|
||||
|
||||
def test_database_pool_enabled_merges_pool_options():
|
||||
"""The pool and keepalive options are added next to the existing ones."""
|
||||
configuration = make_configuration()
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"] == {
|
||||
"sslmode": "require",
|
||||
"keepalives_idle": 60,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
"pool": {"min_size": 1, "max_size": 3, "timeout": 30.0, "max_idle": 600.0},
|
||||
}
|
||||
|
||||
|
||||
def test_database_pool_enabled_keeps_explicit_keepalives():
|
||||
"""Keepalive values already set, e.g. from DATABASE_URL, are not overridden."""
|
||||
configuration = make_configuration(
|
||||
DATABASES={
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"OPTIONS": {"keepalives_idle": "30", "keepalives": "0"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
options = configuration.DATABASES["default"]["OPTIONS"]
|
||||
assert options["keepalives_idle"] == "30"
|
||||
assert options["keepalives"] == "0"
|
||||
assert options["keepalives_interval"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("health_checks", [None, False])
|
||||
def test_database_pool_enabled_forces_health_checks(health_checks):
|
||||
"""Pooled connections are checked on checkout, even if DATABASE_URL said no."""
|
||||
database = {"ENGINE": "django.db.backends.postgresql"}
|
||||
if health_checks is not None:
|
||||
database["CONN_HEALTH_CHECKS"] = health_checks
|
||||
configuration = make_configuration(DATABASES={"default": database})
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["CONN_HEALTH_CHECKS"] is True
|
||||
|
||||
|
||||
def test_database_pool_enabled_without_options():
|
||||
"""A database without OPTIONS, as built from DATABASE_URL, gets them created."""
|
||||
configuration = make_configuration(
|
||||
DATABASES={"default": {"ENGINE": "django.db.backends.postgresql_psycopg2"}}
|
||||
)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"]["pool"]["max_size"] == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides,match",
|
||||
[
|
||||
(
|
||||
{"DATABASES": {"default": {"ENGINE": "django.db.backends.sqlite3"}}},
|
||||
"requires the django.db.backends.postgresql engine",
|
||||
),
|
||||
(
|
||||
{
|
||||
"DATABASES": {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"CONN_MAX_AGE": 60,
|
||||
}
|
||||
}
|
||||
},
|
||||
"cannot be combined with persistent connections",
|
||||
),
|
||||
(
|
||||
{"DB_PSYCOPG_POOL_MIN_SIZE": 6},
|
||||
"MIN_SIZE cannot be greater than",
|
||||
),
|
||||
({"DB_PSYCOPG_POOL_TIMEOUT": 0.0}, "must be positive"),
|
||||
({"DB_PSYCOPG_POOL_MAX_IDLE": -1.0}, "must be positive"),
|
||||
],
|
||||
)
|
||||
def test_database_pool_invalid_configuration(overrides, match):
|
||||
"""An invalid pool setup fails at boot."""
|
||||
configuration = make_configuration(**overrides)
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match=match):
|
||||
configuration._configure_database_pool()
|
||||
@@ -17,6 +17,7 @@ import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
@@ -139,7 +140,7 @@ class Base(Configuration):
|
||||
)
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
"django.db.backends.postgresql",
|
||||
environ_name="DB_ENGINE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
@@ -154,6 +155,21 @@ class Base(Configuration):
|
||||
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
||||
}
|
||||
}
|
||||
DB_PSYCOPG_POOL_ENABLED = values.BooleanValue(
|
||||
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MIN_SIZE = values.PositiveIntegerValue(
|
||||
1, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MAX_SIZE = values.PositiveIntegerValue(
|
||||
3, environ_name="DB_PSYCOPG_POOL_MAX_SIZE", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_TIMEOUT = values.FloatValue(
|
||||
30.0, environ_name="DB_PSYCOPG_POOL_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MAX_IDLE = values.FloatValue(
|
||||
600.0, environ_name="DB_PSYCOPG_POOL_MAX_IDLE", environ_prefix=None
|
||||
)
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
@@ -1446,6 +1462,52 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _configure_database_pool(cls):
|
||||
"""Inject the psycopg pool options into the default database when enabled."""
|
||||
if not cls.DB_PSYCOPG_POOL_ENABLED:
|
||||
return
|
||||
|
||||
database = cls.DATABASES["default"]
|
||||
if database.get("ENGINE") not in (
|
||||
"django.db.backends.postgresql",
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_ENABLED requires the django.db.backends.postgresql "
|
||||
f"engine, got {database.get('ENGINE')!r}."
|
||||
)
|
||||
if database.get("CONN_MAX_AGE"):
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_ENABLED cannot be combined with persistent "
|
||||
"connections; set CONN_MAX_AGE to 0."
|
||||
)
|
||||
if cls.DB_PSYCOPG_POOL_MIN_SIZE > cls.DB_PSYCOPG_POOL_MAX_SIZE:
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_MIN_SIZE cannot be greater than "
|
||||
"DB_PSYCOPG_POOL_MAX_SIZE."
|
||||
)
|
||||
if cls.DB_PSYCOPG_POOL_TIMEOUT <= 0 or cls.DB_PSYCOPG_POOL_MAX_IDLE <= 0:
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_TIMEOUT and DB_PSYCOPG_POOL_MAX_IDLE must be positive."
|
||||
)
|
||||
|
||||
database["OPTIONS"] = {
|
||||
"keepalives_idle": 60,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
**database.get("OPTIONS", {}),
|
||||
"pool": {
|
||||
"min_size": cls.DB_PSYCOPG_POOL_MIN_SIZE,
|
||||
"max_size": cls.DB_PSYCOPG_POOL_MAX_SIZE,
|
||||
"timeout": cls.DB_PSYCOPG_POOL_TIMEOUT,
|
||||
"max_idle": cls.DB_PSYCOPG_POOL_MAX_IDLE,
|
||||
},
|
||||
}
|
||||
# The pool does not watch idle connections: check each one on checkout
|
||||
# so a broken connection is replaced instead of failing the request.
|
||||
database["CONN_HEALTH_CHECKS"] = True
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
@@ -1460,6 +1522,7 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
cls._check_recording_encoding_maps()
|
||||
cls._configure_database_pool()
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"markdown==3.10.3",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.44.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"psycopg[binary,pool]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.14.0",
|
||||
"python-frontmatter==1.3.0",
|
||||
|
||||
Generated
+18
-3
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1331,7 +1331,7 @@ dependencies = [
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-frontmatter" },
|
||||
@@ -1396,7 +1396,7 @@ requires-dist = [
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.37" },
|
||||
{ name = "posthog", specifier = "==7.44.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
|
||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "python-frontmatter", specifier = "==1.3.0" },
|
||||
@@ -1875,6 +1875,9 @@ wheels = [
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
pool = [
|
||||
{ name = "psycopg-pool" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
@@ -1905,6 +1908,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-pool"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user