Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 10dd22642c ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-10 00:55:47 +02:00
28 changed files with 323 additions and 807 deletions
-1
View File
@@ -11,7 +11,6 @@ and this project adheres to
### Added
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ✨(all) allow any authenticated user to start a recording
### Changed
+1 -2
View File
@@ -3,7 +3,7 @@
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
@@ -89,7 +89,6 @@ sequenceDiagram
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
-1
View File
@@ -408,7 +408,6 @@ These are the environmental options available on meet backend.
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
-3
View File
@@ -44,9 +44,6 @@ def get_frontend_configuration(request):
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
"authenticated_participants_enabled": (
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
),
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
+18 -37
View File
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
class ParticipantCapability(permissions.BasePermission):
"""Base permission for in-meeting capabilities opened up to participants.
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners always hold the capability.
- When the room access level is TRUSTED or PUBLIC, any
session-authenticated user who is currently connected to the meeting
holds it too.
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room access level takes effect immediately.
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to perform this action."
enabled_setting = None
message = "You are not allowed to manage this room's lobby."
def has_object_permission(self, request, view, obj):
"""Check privileges first, then the participant presence path."""
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: these capabilities are reserved for session-authenticated
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
@@ -217,35 +217,16 @@ class ParticipantCapability(permissions.BasePermission):
if obj.is_administrator_or_owner(user):
return True
is_open_room = obj.access_level in (
RoomAccessLevel.TRUSTED,
RoomAccessLevel.PUBLIC,
)
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
if not (is_open_room and is_enabled):
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except (ParticipantNotFoundException, ParticipantsManagementException):
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
class CanManageLobby(ParticipantCapability):
"""Grant lobby management (list/accept/deny waiting participants).
Public rooms have no lobby: the endpoints short-circuit there, so opening
them to participants exposes nothing.
"""
class CanManageRecording(ParticipantCapability):
"""Grant starting/stopping a room recording (screen recording or transcript).
Self-hosters can keep it admin/owner only with
`RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED`.
"""
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
+7 -3
View File
@@ -404,7 +404,7 @@ class RoomViewSet(
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.CanManageRecording,
permissions.HasPrivilegesOnRoom,
],
)
@FeatureFlag.require("recording")
@@ -437,7 +437,11 @@ class RoomViewSet(
mode=mode,
options=options_data,
)
recording.grant_initial_accesses(starter=self.request.user)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -481,7 +485,7 @@ class RoomViewSet(
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.CanManageRecording,
permissions.HasPrivilegesOnRoom,
],
)
@FeatureFlag.require("recording")
-23
View File
@@ -690,29 +690,6 @@ class Recording(BaseModel):
"update": False,
}
def grant_initial_accesses(self, starter):
"""Make the starter and the room's owners/administrators OWNER of the recording.
The starter's access is created first, so it is the oldest one: the
transcript summary, which goes to a single owner, is sent to them.
Room roles are copied when the recording starts: later changes to the
room's accesses do not affect existing recordings.
"""
RecordingAccess.objects.create(
user=starter, role=RoleChoices.OWNER, recording=self
)
room_privileged_user_ids = (
self.room.accesses.filter(role__in=[RoleChoices.OWNER, RoleChoices.ADMIN])
.exclude(user=starter)
.values_list("user_id", flat=True)
)
RecordingAccess.objects.bulk_create(
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
for user_id in room_privileged_user_ids
)
def is_savable(self) -> bool:
"""Determine if the recording can be saved based on its current status."""
@@ -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 kombu.exceptions import OperationalError as KombuOperationalError
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.email 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, KombuOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@@ -222,23 +207,6 @@ class NotificationService:
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
)
@staticmethod
def _get_summary_recipient(recording: models.Recording):
"""Return the single user who receives the transcript summary.
A recording can have several owners (its starter and the room's
owners/administrators), but the summary is generated once. It goes to
the oldest owner, which is the starter: their access is created first
(see `Recording.grant_initial_accesses`).
"""
oldest_owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(role=models.RoleChoices.OWNER, recording_id=recording.id)
.order_by("created_at")
.first()
)
return oldest_owner_access.user if oldest_owner_access else None
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording."""
@@ -250,7 +218,14 @@ class NotificationService:
logger.error("Summary service not configured")
return False
recipient = NotificationService._get_summary_recipient(recording)
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
@@ -260,7 +235,7 @@ class NotificationService:
else:
metadata_filename = None
if not recipient:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
@@ -269,16 +244,16 @@ class NotificationService:
)(recording.worker_id)
payload = {
"owner_id": str(recipient.id),
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": recipient.email,
"sub": recipient.sub,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(recipient.timezone),
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": recipient.language,
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
}
@@ -318,7 +293,14 @@ class NotificationService:
logger.error("Summary service not configured")
return False
recipient = NotificationService._get_summary_recipient(recording)
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
metadata_filename: None | str = None
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
@@ -326,7 +308,7 @@ class NotificationService:
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
if not recipient:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
@@ -353,8 +335,8 @@ class NotificationService:
}
payload = {
"user_sub": recipient.sub,
"user_email": recipient.email,
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_url(
recording.key,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
@@ -363,20 +345,20 @@ class NotificationService:
"language": recording.options.get(
"language", get_language().split("-")[0].lower()
),
"context_language": recipient.language,
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": recipient.email,
"user_email": owner_access.user.email,
"title": NotificationService._generate_title(
locale=recipient.language
locale=owner_access.user.language
or recording.options.get("language", get_language()),
room=recording.room.name,
recording_datetime=started_at,
owner_timezone=str(recipient.timezone),
owner_timezone=str(owner_access.user.timezone),
),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"form_link": form_link,
"auto_create_summary": is_user_feature_flag_enabled(
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
},
"metadata": metadata_payload,
+2
View File
@@ -1,9 +1,11 @@
"""Celery tasks for the core app."""
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.email import send_email
from core.tasks.file import process_file_deletion
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
+74
View File
@@ -0,0 +1,74 @@
"""Generic tasks to send transactional emails."""
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:
smtplib.SMTPException: 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 kombu.exceptions import OperationalError as KombuOperationalError
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.email.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.email.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=KombuOperationalError("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(
@@ -479,24 +534,3 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
assert isinstance(title, str)
# ...so the payload serializes exactly the way ``requests`` serializes it.
json.dumps(payload)
def test_summary_recipient_is_oldest_owner():
"""With several owners, the summary goes to the oldest one."""
recording = factories.RecordingFactory()
oldest = factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
for _ in range(2):
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
assert NotificationService._get_summary_recipient(recording) == oldest.user
def test_summary_recipient_none_without_owner():
"""No owner: nobody to send the summary to."""
recording = factories.RecordingFactory()
assert NotificationService._get_summary_recipient(recording) is None
@@ -1,396 +0,0 @@
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RecordingFactory, RoomFactory, UserFactory
from core.models import (
Recording,
RecordingStatusChoices,
RoleChoices,
RoomAccessLevel,
)
from core.recording.event.notification import NotificationService
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_worker_manager():
"""Mock the worker service factory and mediator."""
with (
mock.patch("core.api.viewsets.get_worker_service"),
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
):
mediator = mock.Mock()
mediator_class.return_value = mediator
yield mediator
@pytest.fixture(autouse=True)
def enable_recording(settings):
"""Recording must be enabled for the endpoints to be reachable."""
settings.RECORDING_ENABLE = True
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_open_room_present_user_can_start_recording(
mock_check, access_level, mock_worker_manager
):
"""Authenticated + present in a trusted/public room -> 201, starter owns it."""
user = UserFactory()
room = RoomFactory(access_level=access_level)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
recording = Recording.objects.get()
mock_worker_manager.start.assert_called_once_with(recording)
access = recording.accesses.get()
assert access.user == user
assert access.role == RoleChoices.OWNER
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
"""Presence is memoized like for the lobby; clearing it forces a LiveKit re-check."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
assert client.post(url).status_code == 200
assert client.post(url).status_code == 200
assert mock_check.call_count == 1
# participant_left webhook
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.post(url).status_code == 403
assert mock_check.call_count == 2
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=False,
)
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
"""Authenticated but not connected to the meeting -> 403."""
room = RoomFactory(access_level=access_level)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to perform this action."}
assert Recording.objects.count() == 0
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 403
mock_check.assert_not_called()
assert Recording.objects.count() == 0
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_open_room_present_user_can_stop_recording(
mock_check, access_level, mock_worker_manager
):
"""Any present authenticated user can stop the active recording."""
room = RoomFactory(access_level=access_level)
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 200
mock_worker_manager.stop.assert_called_once_with(recording)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room.accesses.create(user=user, role=RoleChoices.ADMIN)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_not_called()
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
def test_open_room_anonymous_forbidden(access_level):
"""Anonymous users never record, even on public rooms."""
room = RoomFactory(access_level=access_level)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 401
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_path_disabled_by_setting(
mock_check, access_level, settings, mock_worker_manager
):
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
room = RoomFactory(access_level=access_level)
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
user = UserFactory()
client = APIClient()
client.force_login(user)
start = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert start.status_code == 403
assert stop.status_code == 403
mock_check.assert_not_called()
mock_worker_manager.start.assert_not_called()
mock_worker_manager.stop.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_participant_path_disabled_admin_still_records(
mock_check, settings, mock_worker_manager
):
"""The setting only removes the participant path, never admin rights."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=user, role=RoleChoices.OWNER)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
"""The recording opt-out must not leak into the lobby capability."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_public_room_lobby_is_open_but_empty(mock_check):
"""Participants pass the lobby permission on public rooms, which have no lobby."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
entry = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert listing.status_code == 200
assert listing.json() == {"participants": []}
assert entry.status_code == 404
assert entry.json() == {"message": "Room has no lobby system."}
@pytest.mark.parametrize("enabled", [True, False])
def test_config_exposes_authenticated_participants_setting(settings, enabled):
"""The frontend reads the setting from /config/ to gate the UI."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
response = APIClient().get("/api/v1.0/config/")
assert response.status_code == 200
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_room_owners_and_admins_get_access_to_participant_recording(
mock_check, mock_worker_manager
):
"""Starter and room owners/admins all own the recording; members get nothing."""
starter = UserFactory()
room_owner = UserFactory()
room_admin = UserFactory()
room_member = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert dict(recording.accesses.values_list("user_id", "role")) == {
starter.id: RoleChoices.OWNER,
room_owner.id: RoleChoices.OWNER,
room_admin.id: RoleChoices.OWNER,
}
# Room admins can see and download it; plain members cannot.
client.force_login(room_admin)
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
client.force_login(room_member)
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
"""A room admin who starts the recording gets a single access."""
starter = UserFactory()
co_owner = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert dict(recording.accesses.values_list("user_id", "role")) == {
starter.id: RoleChoices.OWNER,
co_owner.id: RoleChoices.OWNER,
}
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_can_stop_recording_started_by_someone_else(
mock_check, mock_worker_manager
):
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 200
mock_worker_manager.stop.assert_called_once_with(recording)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
"""The starter's access is created first, so the summary goes to them."""
starter = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
for _ in range(2):
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert recording.accesses.count() == 3
assert NotificationService._get_summary_recipient(recording) == starter
@@ -11,7 +11,7 @@ from livekit import api as livekit_api
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Recording, RoomAccessLevel
from ...models import Recording
from ...recording.worker.exceptions import RecordingStartError
pytestmark = pytest.mark.django_db
@@ -42,13 +42,8 @@ def mock_worker_manager(mock_worker_service):
yield mock_mediator
@pytest.mark.parametrize(
"recording_enabled, expected_status",
[(True, 401), (False, 404)],
)
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
def test_start_recording_anonymous():
"""Anonymous users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = recording_enabled
room = RoomFactory()
client = APIClient()
@@ -57,14 +52,14 @@ def test_start_recording_anonymous(settings, recording_enabled, expected_status)
{"mode": "screen_recording"},
)
assert response.status_code == expected_status
assert response.status_code == 401
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator(settings):
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -10,7 +10,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, RoomFactory, UserFactory
from ...models import Recording, RecordingStatusChoices, RoomAccessLevel
from ...models import Recording, RecordingStatusChoices
from ...recording.worker.exceptions import RecordingStopError
pytestmark = pytest.mark.django_db
@@ -41,20 +41,15 @@ def mock_worker_manager(mock_worker_service):
yield mock_mediator
@pytest.mark.parametrize(
"recording_enabled, expected_status",
[(True, 401), (False, 404)],
)
def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
def test_stop_recording_anonymous():
"""Anonymous users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = recording_enabled
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == expected_status
assert response.status_code == 401
# Verify recording status hasn't changed
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
@@ -62,7 +57,7 @@ def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
def test_stop_recording_non_owner_and_non_administrator(settings):
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
@@ -0,0 +1,89 @@
"""Tests for the generic send_email task."""
# pylint: disable=unused-argument,redefined-outer-name
import smtplib
from unittest import mock
from django.utils.translation import gettext_noop
import pytest
from core.tasks.email 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.email.send_mail")
@mock.patch("core.tasks.email.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.email.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.email.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
)
@mock.patch("core.tasks.email.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"],
)
-8
View File
@@ -793,14 +793,6 @@ class Base(Configuration):
RECORDING_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_ENABLE", environ_prefix=None
)
# Let any authenticated participant connected to a trusted or public room
# start/stop recordings. Disable to keep recording reserved to room
# admins/owners.
RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = values.BooleanValue(
True,
environ_name="RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED",
environ_prefix=None,
)
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
)
-1
View File
@@ -36,7 +36,6 @@ export interface ApiConfig {
available_modes?: RecordingMode[]
expiration_days?: number
max_duration?: number
authenticated_participants_enabled?: boolean
}
background_image: {
upload_is_enabled: boolean
@@ -26,10 +26,6 @@ import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import {
useCanRecord,
useIsRecordingOpenToParticipants,
} from '../hooks/useCanRecord'
import { FeatureFlags } from '@/features/analytics/enums'
import { LimitDescription } from './LimitDescription'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
@@ -43,8 +39,6 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
@@ -122,13 +116,11 @@ export const ScreenRecordingSidePanel = () => {
}
}
if (!canRecord) {
if (!isAdminOrOwner) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey={
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_recording}
imagePath="/assets/intro-slider/4.png"
handleRequest={handleRequestScreenRecording}
@@ -6,8 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useHasFeatureWithoutRecordingRights,
useIsRecordingOpenToParticipants,
useHasFeatureWithoutAdminRights,
useRecordingStatuses,
} from '../index'
import { useState } from 'react'
@@ -58,13 +57,12 @@ export const TranscriptSidePanel = () => {
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights(
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isAdminOrOwner = useIsAdminOrOwner()
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
@@ -141,13 +139,11 @@ export const TranscriptSidePanel = () => {
}
}
if (hasFeatureWithoutRecordingRights) {
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey={
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
@@ -1,30 +0,0 @@
import { useConfig } from '@/api/useConfig'
import { useUser } from '@/features/auth/api/useUser'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
// Room access levels on which authenticated participants can record.
const OPEN_ACCESS_LEVELS = [ApiAccessLevel.TRUSTED, ApiAccessLevel.PUBLIC]
export const useIsRecordingOpenToParticipants = () => {
const roomData = useRoomData()
const { data: config } = useConfig()
return (
config?.recording?.authenticated_participants_enabled === true &&
!!roomData?.id &&
!!roomData?.access_level &&
OPEN_ACCESS_LEVELS.includes(roomData.access_level)
)
}
export const useCanRecord = () => {
const isAdminOrOwner = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
return (
isAdminOrOwner || (isLoggedIn === true && isRecordingOpenToParticipants)
)
}
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useCanRecord } from './useCanRecord'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { FeatureFlags } from '@/features/analytics/enums'
export const useHasFeatureWithoutRecordingRights = (
export const useHasFeatureWithoutAdminRights = (
mode: RecordingMode,
featureFlag: FeatureFlags
) => {
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const canRecord = useCanRecord()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isRecordingModeEnabled &&
!canRecord
!isAdminOrOwner
)
}
@@ -2,7 +2,7 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useCanRecord } from './useCanRecord'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { FeatureFlags } from '@/features/analytics/enums'
export const useHasRecordingAccess = (
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const canRecord = useCanRecord()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
canRecord &&
isAdminOrOwner &&
isRecordingModeEnabled
)
}
+1 -5
View File
@@ -1,11 +1,7 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights'
export {
useCanRecord,
useIsRecordingOpenToParticipants,
} from './hooks/useCanRecord'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
// api
-32
View File
@@ -507,22 +507,6 @@
"buttonLabel": "Anfragen"
}
},
"notLoggedIn": {
"heading": "Zum Transkribieren anmelden",
"body": "In diesem Meeting kann jede angemeldete Person eine Transkription (Beta) starten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen – öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Transkription zu starten."
},
"request": {
"heading": "Transkription anfragen",
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfragen"
}
},
"premium": {
"heading": "Advanced-Funktion",
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
@@ -576,22 +560,6 @@
"buttonLabel": "Anfrage senden"
}
},
"notLoggedIn": {
"heading": "Zum Aufnehmen anmelden",
"body": "In diesem Meeting kann jede angemeldete Person eine Videoaufnahme (Beta) starten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen – öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Aufnahme zu starten."
},
"request": {
"heading": "Aufnahme anfragen",
"body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.",
"buttonLabel": "Anfrage senden"
}
},
"premium": {
"heading": "Advanced-Funktion",
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
-32
View File
@@ -507,22 +507,6 @@
"buttonLabel": "Request"
}
},
"notLoggedIn": {
"heading": "Log in to transcribe",
"body": "In this meeting, any logged-in participant can start a transcription (beta).",
"linkMore": "Open documentation",
"linkAriaLabel": "Open documentation about transcription access - opens in new window",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Log in, then come back to this panel to start the transcription."
},
"request": {
"heading": "Request Transcription",
"body": "The host will be notified and can enable transcription for you.",
"buttonLabel": "Request"
}
},
"premium": {
"heading": "Advanced feature",
"body": "This feature is not available to you. Please contact support for more information.",
@@ -576,22 +560,6 @@
"buttonLabel": "Request"
}
},
"notLoggedIn": {
"heading": "Log in to record",
"body": "In this meeting, any logged-in participant can start a recording (beta).",
"linkMore": "Open documentation",
"linkAriaLabel": "Open documentation about recording access - opens in new window",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Log in, then come back to this panel to start the recording."
},
"request": {
"heading": "Request Recording",
"body": "The host will be notified and can enable recording for you.",
"buttonLabel": "Request"
}
},
"premium": {
"heading": "Advanced feature",
"body": "This feature is not available to you. Please contact support for more information.",
-32
View File
@@ -506,22 +506,6 @@
"buttonLabel": "Solicitar"
}
},
"notLoggedIn": {
"heading": "Inicia sesión para transcribir",
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una transcripción (beta).",
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre el acceso a la transcripción - se abre en una nueva ventana",
"dividerLabel": "O",
"login": {
"heading": "Inicio de sesión necesario",
"body": "Inicia sesión y vuelve a este panel para iniciar la transcripción."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.",
"buttonLabel": "Solicitar"
}
},
"premium": {
"heading": "Función avanzada",
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
@@ -575,22 +559,6 @@
"buttonLabel": "Solicitar"
}
},
"notLoggedIn": {
"heading": "Inicia sesión para grabar",
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una grabación (beta).",
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre el acceso a la grabación - se abre en una nueva ventana",
"dividerLabel": "O",
"login": {
"heading": "Inicio de sesión necesario",
"body": "Inicia sesión y vuelve a este panel para iniciar la grabación."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.",
"buttonLabel": "Solicitar"
}
},
"premium": {
"heading": "Función avanzada",
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
-32
View File
@@ -507,22 +507,6 @@
"buttonLabel": "Demander"
}
},
"notLoggedIn": {
"heading": "Connectez-vous pour transcrire",
"body": "Dans cette réunion, tout participant connecté peut lancer une transcription (beta).",
"linkMore": "Ouvrir la documentation",
"linkAriaLabel": "Ouvrir la documentation sur l'accès à la transcription - ouvre dans une nouvelle fenêtre",
"dividerLabel": "OU",
"login": {
"heading": "Connexion requise",
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer la transcription."
},
"request": {
"heading": "Demander à l'organisateur",
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
"buttonLabel": "Demander"
}
},
"premium": {
"heading": "Fonctionnalité avancée",
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
@@ -576,22 +560,6 @@
"buttonLabel": "Demander"
}
},
"notLoggedIn": {
"heading": "Connectez-vous pour enregistrer",
"body": "Dans cette réunion, tout participant connecté peut lancer un enregistrement (beta).",
"linkMore": "Ouvrir la documentation",
"linkAriaLabel": "Ouvrir la documentation sur l'accès à l'enregistrement - ouvre dans une nouvelle fenêtre",
"dividerLabel": "OU",
"login": {
"heading": "Connexion requise",
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer l'enregistrement."
},
"request": {
"heading": "Demander à l'organisateur",
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
"buttonLabel": "Demander"
}
},
"premium": {
"heading": "Fonctionnalité avancée",
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
-32
View File
@@ -507,22 +507,6 @@
"buttonLabel": "Aanvragen"
}
},
"notLoggedIn": {
"heading": "Log in om te transcriberen",
"body": "In deze vergadering kan elke ingelogde deelnemer een transcriptie starten (beta).",
"linkMore": "Documentatie openen",
"linkAriaLabel": "Documentatie over transcriptietoegang openen - opent in nieuw venster",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
"body": "Log in en kom daarna terug naar dit paneel om de transcriptie te starten."
},
"request": {
"heading": "Transcriptie aanvragen",
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
"buttonLabel": "Aanvragen"
}
},
"premium": {
"heading": "Geavanceerde functie",
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
@@ -576,22 +560,6 @@
"buttonLabel": "Aanvragen"
}
},
"notLoggedIn": {
"heading": "Log in om op te nemen",
"body": "In deze vergadering kan elke ingelogde deelnemer een opname starten (beta).",
"linkMore": "Documentatie openen",
"linkAriaLabel": "Documentatie over opnametoegang openen - opent in nieuw venster",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
"body": "Log in en kom daarna terug naar dit paneel om de opname te starten."
},
"request": {
"heading": "Opname aanvragen",
"body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.",
"buttonLabel": "Aanvragen"
}
},
"premium": {
"heading": "Geavanceerde functie",
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",