mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-14 00:50:48 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d7209836 | |||
| 4854fc0c13 | |||
| d930fa1ba6 | |||
| 6f1414b34e | |||
| 0194f7f3f3 | |||
| 0bde103911 | |||
| c7c41abcb5 |
@@ -22,6 +22,9 @@
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# Use "search" semantics for regexes (gitlint's future default)
|
||||
regex-style-search=True
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
|
||||
@@ -10,8 +10,16 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
- 🔒️(backend) throttle the start recording endpoint
|
||||
|
||||
## [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
|
||||
|
||||
### 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"
|
||||
|
||||
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
|
||||
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
|
||||
@@ -335,6 +335,8 @@ These are the environmental options available on meet backend.
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
|
||||
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
|
||||
| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute |
|
||||
| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/day |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
@@ -357,6 +359,7 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -47,6 +47,18 @@ class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
|
||||
scope = "room_creation_daily"
|
||||
|
||||
|
||||
class StartRecordingUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle recording starts per authenticated user."""
|
||||
|
||||
scope = "start_recording"
|
||||
|
||||
|
||||
class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle):
|
||||
"""Cap recording starts per authenticated user over a day."""
|
||||
|
||||
scope = "start_recording_daily"
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
|
||||
@@ -406,6 +406,10 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
throttle_classes=[
|
||||
throttling.StartRecordingUserRateThrottle,
|
||||
throttling.StartRecordingDailyUserRateThrottle,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.mail import send_email
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,10 +68,14 @@ class NotificationService:
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
Queue an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
|
||||
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||
this only does one DB query and enqueues one message per owner.
|
||||
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
@@ -88,10 +92,6 @@ class NotificationService:
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
@@ -104,37 +104,22 @@ class NotificationService:
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||
try:
|
||||
send_email.delay(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=[user.email],
|
||||
language=user.language or get_language(),
|
||||
context={
|
||||
**context,
|
||||
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||
"recording_time": local_created_at.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = gettext("Your recording is ready") # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
except (smtplib.SMTPException, CeleryOperationalError) as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
"send_email",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Send mail using celery task."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_email_context() -> dict:
|
||||
"""Context shared by every email template (branding, support, domain)."""
|
||||
return {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
}
|
||||
|
||||
|
||||
@task(
|
||||
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
retry_jitter=True,
|
||||
max_retries=5,
|
||||
)
|
||||
def send_email(
|
||||
*,
|
||||
template: str,
|
||||
subject: str,
|
||||
recipients: list[str],
|
||||
context: dict | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""Render a mail template and send it.
|
||||
|
||||
Generic on purpose: any feature can reuse it with its own template and context.
|
||||
|
||||
Args:
|
||||
template: Template base name, e.g. "screen_recording". Both
|
||||
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||
call site; it is translated here, in ``language``.
|
||||
recipients: Email addresses, all receiving the same rendered message.
|
||||
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||
language: Language to render the email in. Defaults to the active language.
|
||||
|
||||
Raises:
|
||||
OSError: SMTP or network failure, after retries are exhausted (or
|
||||
immediately when Celery is disabled and the call runs synchronously).
|
||||
"""
|
||||
full_context = {**get_base_email_context(), **(context or {})}
|
||||
|
||||
with override(language or get_language()):
|
||||
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||
translated_subject = gettext(subject)
|
||||
|
||||
send_mail(
|
||||
translated_subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
recipients,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||
@@ -12,6 +12,7 @@ from unittest import mock
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_mail",
|
||||
"core.tasks.mail.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_queues_generic_task(settings):
|
||||
"""One generic send_email task is queued per owner, with serializable args."""
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
recording.created_at = datetime.datetime(
|
||||
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||
)
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_delay.assert_called_once_with(
|
||||
template="screen_recording",
|
||||
subject="Your recording is ready",
|
||||
recipients=["franc@test.com"],
|
||||
language="fr-fr",
|
||||
context={
|
||||
"room_name": "Conference Room A",
|
||||
"recording_expiration_days": 7,
|
||||
"link": f"https://acme.com/recordings/{recording.id}",
|
||||
"recording_date": "2023-05-15",
|
||||
"recording_time": "16:30",
|
||||
},
|
||||
)
|
||||
# Celery's JSON serializer must accept every argument
|
||||
json.dumps(mock_delay.call_args.kwargs)
|
||||
|
||||
|
||||
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||
"""A broker outage is reported as a failure without crashing the caller."""
|
||||
recording = factories.RecordingFactory()
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_email.delay",
|
||||
side_effect=CeleryOperationalError("broker down"),
|
||||
):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert "notification could not be sent: broker down" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start recording throttling.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...api.throttling import (
|
||||
StartRecordingDailyUserRateThrottle,
|
||||
StartRecordingUserRateThrottle,
|
||||
)
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def start_recording_throttle(monkeypatch):
|
||||
"""Lower the start recording rates."""
|
||||
rates = StartRecordingUserRateThrottle.THROTTLE_RATES
|
||||
monkeypatch.setitem(rates, "start_recording", "2/minute")
|
||||
monkeypatch.setitem(rates, "start_recording_daily", "100/day")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_start_recording_throttle(monkeypatch):
|
||||
"""Lower the daily start recording rate and control the clock."""
|
||||
rates = StartRecordingDailyUserRateThrottle.THROTTLE_RATES
|
||||
monkeypatch.setitem(rates, "start_recording", "100/minute")
|
||||
monkeypatch.setitem(rates, "start_recording_daily", "3/day")
|
||||
now = [1_000_000.0]
|
||||
monkeypatch.setattr(StartRecordingUserRateThrottle, "timer", lambda self: now[0])
|
||||
return now
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttled(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Starting a recording should return 429 when exceeding the per-minute rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(2):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert 0 < int(response["Retry-After"]) <= 60
|
||||
assert Recording.objects.count() == 2
|
||||
assert mock_mediator_class.return_value.start.call_count == 2
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_counts_failed_attempts(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Failed recording starts should count toward the rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_per_user(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Starting a recording should succeed when another user is throttled."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
client = APIClient()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
for _ in range(2):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
other_user = UserFactory()
|
||||
client.force_login(other_user)
|
||||
room = RoomFactory(users=[(other_user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_does_not_limit_other_actions(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Other room actions should succeed when starting a recording is throttled."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
rooms = RoomFactory.create_batch(3, users=[(user, "owner")])
|
||||
for room in rooms[:2]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{rooms[2].id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
Recording.objects.filter(room=rooms[0]).update(status="active")
|
||||
response = client.post(f"/api/v1.0/rooms/{rooms[0].id}/stop-recording/")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "New room"})
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_daily_throttled(
|
||||
mock_get_worker_service,
|
||||
mock_mediator_class,
|
||||
daily_start_recording_throttle,
|
||||
settings,
|
||||
):
|
||||
"""Starting a recording should return 429 when exceeding the daily rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
now = daily_start_recording_throttle
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(3):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
now[0] += 120
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert 60 < int(response["Retry-After"]) <= 86400
|
||||
|
||||
now[0] += 86400
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the generic send_email task."""
|
||||
|
||||
# pylint: disable=unused-argument,redefined-outer-name
|
||||
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.core.mail import get_connection
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_settings(settings):
|
||||
"""Branding settings shared by every email."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
return settings
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||
def test_send_email_renders_template_and_sends(
|
||||
mock_render, mock_send_mail, email_settings
|
||||
):
|
||||
"""Both template variants are rendered with base + caller context, then sent."""
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com", "b@test.com"],
|
||||
context={"foo": "bar", "brandname": "Overridden"},
|
||||
)
|
||||
|
||||
expected_context = {
|
||||
"brandname": "Overridden",
|
||||
"support_email": "support@acme.com",
|
||||
"logo_img": "https://acme.com/logo",
|
||||
"domain": "acme.com",
|
||||
"foo": "bar",
|
||||
}
|
||||
assert mock_render.call_args_list == [
|
||||
mock.call("mail/html/some_template.html", expected_context),
|
||||
mock.call("mail/text/some_template.txt", expected_context),
|
||||
]
|
||||
mock_send_mail.assert_called_once_with(
|
||||
"Any subject",
|
||||
"text",
|
||||
"notifications@acme.com",
|
||||
["a@test.com", "b@test.com"],
|
||||
html_message="<p>html</p>",
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
def test_send_email_translates_subject_in_requested_language(
|
||||
mock_send_mail, email_settings
|
||||
):
|
||||
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||
send_email(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=["franc@test.com"],
|
||||
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||
language="fr-fr",
|
||||
)
|
||||
|
||||
subject, body, *_ = mock_send_mail.call_args[0]
|
||||
assert subject == "Votre enregistrement est prêt"
|
||||
assert "Votre enregistrement est prêt !" in body
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||
)
|
||||
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
|
||||
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||
"""SMTP errors propagate so Celery can retry the task."""
|
||||
with pytest.raises(smtplib.SMTPException):
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com"],
|
||||
)
|
||||
|
||||
|
||||
def test_smtp_backend_uses_email_timeout(settings):
|
||||
"""A stalled SMTP server cannot hold the worker forever."""
|
||||
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
settings.EMAIL_TIMEOUT = 12
|
||||
|
||||
assert get_connection().timeout == 12
|
||||
@@ -395,6 +395,16 @@ class Base(Configuration):
|
||||
environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"start_recording": values.Value(
|
||||
default="10/minute",
|
||||
environ_name="START_RECORDING_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"start_recording_daily": values.Value(
|
||||
default="100/day",
|
||||
environ_name="START_RECORDING_DAILY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"request_entry": values.Value(
|
||||
default="150/minute",
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
@@ -508,6 +518,7 @@ class Base(Configuration):
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.34.0"
|
||||
version = "1.35.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
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