Compare commits

..

20 Commits

Author SHA1 Message Date
lebaudantoine c6cb735a09 wip poc a email notification 2025-04-10 15:59:38 +02:00
lebaudantoine 461ab70918 fixup! wip generate secure signed link to download file from the storage 2025-04-10 15:47:52 +02:00
lebaudantoine bc6bf73e71 wip introduce property is_saved 2025-04-10 15:44:37 +02:00
lebaudantoine 566b085ede fixup! wip introduce screen recording 2025-04-10 15:43:59 +02:00
lebaudantoine 84d51bc86c wip add La Suite logo 2025-04-10 14:47:25 +02:00
lebaudantoine 66eab072ee wip extract key computation in a proper property method 2025-04-10 14:46:47 +02:00
lebaudantoine f390ad46d5 wip page to download a recording 2025-04-09 23:02:40 +02:00
lebaudantoine 6bdf1d173b wip generate secure signed link to download file from the storage 2025-04-09 20:30:45 +02:00
lebaudantoine 02cc74e06f wip update serializer and viewset for recordings 2025-04-09 20:30:18 +02:00
lebaudantoine 784d97efd6 wip introduce utils to generate s3 authorization headers 2025-04-09 20:29:46 +02:00
lebaudantoine 1f44edcdf3 wip create a new ingress for media file 2025-04-09 20:28:54 +02:00
lebaudantoine 92e87fcd32 wip enable one of the recording not both 2025-04-09 11:32:45 +02:00
lebaudantoine 5138f66262 wip introduce screen recording 2025-04-07 22:06:28 +02:00
lebaudantoine 9a21404d23 wip introduce hook useHasScreenRecordingAccess 2025-04-07 22:06:28 +02:00
lebaudantoine 85784419a3 wip make generic and reusable the recording state toast 2025-04-07 22:06:28 +02:00
lebaudantoine 3431df05af wip generalize recording toast key name 2025-04-07 22:06:28 +02:00
lebaudantoine 4e9dd87a7a wip support new sub panel id 'recording' 2025-04-07 22:06:28 +02:00
lebaudantoine e4c27aa840 wip handle recording notification 2025-04-07 22:06:28 +02:00
lebaudantoine 280cd01df5 wip clean dead code 2025-04-07 11:03:16 +02:00
lebaudantoine 1a552282a5 wip refactor transcript keys 2025-04-07 10:53:00 +02:00
88 changed files with 745 additions and 3180 deletions
-30
View File
@@ -135,9 +135,6 @@ jobs:
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
@@ -155,33 +152,6 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
with:
-33
View File
@@ -15,37 +15,6 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -71,7 +40,6 @@ services:
- redis
- nginx
- livekit
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -105,7 +73,6 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
-11
View File
@@ -12,20 +12,12 @@ PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN=localhost:3000
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
# Backend url
MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -50,6 +42,3 @@ LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
# Recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
+1 -1
View File
@@ -94,7 +94,7 @@ class HasAbilityPermission(IsAuthenticated):
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges on room to perform this action."
message = "You must have privileges to start a recording."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
+7 -21
View File
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status", "mode", "key"]
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
read_only_fields = fields
@@ -189,11 +189,11 @@ class RequestEntrySerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
@@ -204,11 +204,11 @@ class ParticipantEntrySerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
@@ -218,22 +218,8 @@ class CreationCallbackSerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
raise NotImplementedError("StartRecordingSerializer is validation-only")
+13 -48
View File
@@ -4,6 +4,7 @@ import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
from django.http import Http404
@@ -21,8 +22,7 @@ from rest_framework import (
status as drf_status,
)
from core import enums, models, utils
from core.recording.enums import FileExtension
from core import models, utils, enums
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -41,7 +41,6 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -366,7 +365,7 @@ class RoomViewSet(
@decorators.action(
detail=True,
methods=["post"],
methods=["POST"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
@@ -449,7 +448,7 @@ class RoomViewSet(
@decorators.action(
detail=False,
methods=["post"],
methods=["POST"],
url_path="webhooks-livekit",
permission_classes=[],
)
@@ -475,7 +474,7 @@ class RoomViewSet(
@decorators.action(
detail=False,
methods=["post"],
methods=["POST"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
@@ -498,38 +497,6 @@ class RoomViewSet(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="invite",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def invite(self, request, pk=None): # pylint: disable=unused-argument
"""Send email invitations to join a room.
This API endpoint allows a user with appropriate privileges to send email invitations
to one or more recipients, inviting them to join the specified room.
"""
room = self.get_object()
serializer = serializers.RoomInviteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
emails = serializer.validated_data.get("emails")
emails = list(set(emails))
InvitationService().invite_to_room(
room=room, sender=request.user, emails=emails
)
return drf_response.Response(
{"status": "success", "message": "invitations sent"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -627,7 +594,7 @@ class RecordingViewSet(
)
try:
recording = models.Recording.objects.get(id=recording_id)
recording = models.Recording.objects.select_related('room').get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
@@ -654,14 +621,17 @@ class RecordingViewSet(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
@@ -695,6 +665,7 @@ class RecordingViewSet(
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
@@ -708,30 +679,24 @@ class RecordingViewSet(
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
recording_id = url_params['recording']
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
logger.debug("User '%s' lacks permission for attachment", user)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+5 -5
View File
@@ -1,22 +1,22 @@
"""
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
RECORDING_FOLDER = 'recordings'
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
f"/media/recordings/(?P<recording>{UUID_REGEX:s})(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+4 -17
View File
@@ -18,8 +18,6 @@ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -580,27 +578,16 @@ class Recording(BaseModel):
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
"""Wip"""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
}
@property
def extension(self):
"""Get recording extension based on its mode."""
extensions = {
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
}
return extensions.get(self.mode, FileExtension.MP4.value)
@property
def key(self):
"""Generate the file key based on recording mode."""
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
"""Wip."""
extension = "ogg" if self.mode == RecordingModeChoices.TRANSCRIPT else "mp4"
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{extension}"
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
-10
View File
@@ -1,10 +0,0 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -4,10 +4,11 @@ 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, override
from django.utils.translation import gettext_lazy as _
from django.contrib.sites.models import Site
from django.core.mail import send_mail
import requests
@@ -37,16 +38,19 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
"""Wip."""
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
# subject
# emails
domain = Site.objects.get_current().domain
owner_accesses = models.RecordingAccess.objects.select_related("user").filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.all()
)
if not owner_accesses:
@@ -55,16 +59,18 @@ class NotificationService:
language = get_language()
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_date": recording.created_at.strftime("%A %d %B %Y"),
"recording_time": recording.created_at.strftime("%H:%M"),
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
context = (
{
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": domain,
"room_name": recording.room.name,
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
"recording_time": recording.created_at.strftime("%H:%M"),
"link": f"{domain}/recordings/{recording.id}",
}
)
emails = [access.user.email for access in owner_accesses]
@@ -83,11 +89,12 @@ class NotificationService:
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
logger.error("notification to %s was not sent: %s", emails, exception)
return False
return True
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -7,7 +7,6 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -90,9 +89,7 @@ class VideoCompositeEgressService(BaseEgressService):
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
filepath = self._get_filepath(filename=recording_id, extension="mp4")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -101,7 +98,8 @@ class VideoCompositeEgressService(BaseEgressService):
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
room_name=room_name,
file_outputs=[file_output],
)
response = self._handle_request(request, "start_room_composite_egress")
@@ -122,9 +120,7 @@ class AudioCompositeEgressService(BaseEgressService):
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
filepath = self._get_filepath(filename=recording_id, extension="ogg")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
-59
View File
@@ -1,59 +0,0 @@
"""Invitation Service."""
import smtplib
from logging import getLogger
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, override
from django.utils.translation import gettext_lazy as _
logger = getLogger(__name__)
class InvitationError(Exception):
"""Exception raised when invitation emails cannot be sent."""
status_code = 500
class InvitationService:
"""Service for invitations to users."""
@staticmethod
def invite_to_room(room, sender, emails):
"""Send invitation emails to join a room."""
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
"sender_email": sender.email,
}
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(
_(
f"Video call in progress: {sender.email} is waiting for you to connect"
)
) # Force translation
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e
@@ -1,167 +0,0 @@
"""
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
import smtplib
from unittest import mock
from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@pytest.fixture
def mocked_current_site():
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
site_mock = mock.Mock()
site_mock.domain = "test-domain.com"
with mock.patch.object(
Site.objects, "get_current", return_value=site_mock
) as patched:
yield patched
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
def test_notify_external_services_transcript_mode(mock_notify_summary):
"""Test notification routing for transcript mode recordings."""
service = NotificationService()
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
result = service.notify_external_services(recording)
assert result is True
mock_notify_summary.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode(mock_notify_email):
"""Test notification routing for screen recording mode."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
# Bypass validation
recording.mode = "unknown"
service = NotificationService()
result = service.notify_external_services(recording)
assert result is False
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
owners = [
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
]
owner_emails = [owner.email for owner in owners]
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert subject == "Your recording is ready"
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
recording.created_at.strftime("%A %d %B %Y"), # Formatted date
recording.created_at.strftime("%H:%M"), # Formatted time
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(owner_emails)
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
# Recording with no access
recording = factories.RecordingFactory()
result = notification_service._notify_user_by_email(recording)
assert result is False
assert f"No owner found for recording {recording.id}" in caplog.text
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
"""Test email notification when an exception occurs."""
recording = factories.RecordingFactory(room__name="Conference Room A")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is False
mock_send_mail.assert_called_once()
assert "notification could not be sent:" in caplog.text
@@ -22,27 +22,6 @@ def test_api_recordings_list_anonymous():
assert response.status_code == 401
def test_api_recordings_list_authenticated_no_recording():
"""
Authenticated users listing recordings should only
see recordings to which they have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.UserRecordingAccessFactory(user=other_user)
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert results == []
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
@@ -78,9 +57,7 @@ def test_api_recordings_list_authenticated_direct(role):
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -1,284 +0,0 @@
"""
Test media-auth authorization API endpoint in docs core app.
"""
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import models
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = APIClient().get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_documents_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_documents_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
def test_api_documents_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_documents_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_documents_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=wrong_status)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_documents_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(
status=models.RecordingStatusChoices.SAVED,
mode=models.RecordingModeChoices.TRANSCRIPT,
)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found with this extension."}
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_documents_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = (
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
)
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 400
assert response.json() == {"detail": "Unsupported extension."}
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@@ -1,155 +0,0 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
client = APIClient()
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
The API returns 404 instead of 403 to avoid revealing the existence of
resources the user doesn't have permission to access.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
other_user = UserFactory()
recording = UserRecordingAccessFactory(user=other_user).recording
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_recording_retrieve_administrators():
"""A user who is an administrator of a recording should be able to retrieve it."""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
}
def test_api_recording_retrieve_owners():
"""A user who is an owner of a recording should be able to retrieve it."""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
}
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
],
)
def test_api_recording_retrieve_any_status(status):
"""Test that recordings with any status can be retrieved."""
user = UserFactory()
recording = RecordingFactory(status=status)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(recording.id)
assert content["status"] == status
@@ -1,295 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=W0621,W0613
import json
import random
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...services.invitation import InvitationError, InvitationService
pytestmark = pytest.mark.django_db
def test_api_rooms_invite_anonymous():
"""Test anonymous users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 401
def test_api_rooms_invite_no_access():
"""Test non-privileged users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_member():
"""Test member users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
room.accesses.create(user=user, role="member")
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_missing_emails():
"""Test missing email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"foo": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This field is required.",
]
}
def test_api_rooms_invite_empty_emails():
"""Test empty email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This list may not be empty.",
]
}
def test_api_rooms_invite_invalid_emails():
"""Test invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["abdc", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"0": ["Enter a valid email address."],
"1": ["Enter a valid email address."],
}
}
def test_api_rooms_invite_partially_invalid_emails():
"""Test partially invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabrice@yopmail.com", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"1": ["Enter a valid email address."],
}
}
@mock.patch.object(InvitationService, "invite_to_room")
def test_api_rooms_invite_duplicates(mock_invite_to_room):
"""Test duplicate emails should be deduplicated before processing."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
mock_invite_to_room.assert_called_once()
_, kwargs = mock_invite_to_room.call_args
assert kwargs["room"] == room
assert kwargs["sender"] == user
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
def test_api_rooms_invite_error(mock_invite_to_room):
"""Test invitation service error should return appropriate error response."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError) as excinfo:
client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
mock_invite_to_room.assert_called_once()
assert "Could not send invitation" in str(excinfo.value)
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
settings.EMAIL_DOMAIN = "acme.com"
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -12,8 +12,7 @@ from core.factories import (
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
from core.recording.enums import FileExtension
from core.models import Recording, RecordingStatusChoices
pytestmark = pytest.mark.django_db
@@ -217,74 +216,3 @@ def test_models_recording_worker_id_very_long():
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)
# Test key property method
def test_models_recording_key_for_transcript(settings):
"""Test key property returns correct path for transcript mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
assert recording.key == expected_path
def test_models_recording_key_for_screen_recording(settings):
"""Test key property returns correct path for screen recording mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
def test_models_recording_key_for_unknown_mode(settings):
"""Test key property uses default extension for unknown mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory()
# Directly set an invalid mode (bypassing validation)
recording.mode = "unknown_mode"
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
# Test is_saved method
def test_models_recording_is_saved_true():
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_saved is True
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
assert recording.is_saved is True
def test_models_recording_is_saved_false_active():
"""Test is_saved property returns False for ACTIVE status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_saved is False
def test_models_recording_is_saved_false_initiated():
"""Test is_saved property returns False for INITIATED status."""
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
assert recording.is_saved is False
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_saved_false_error_states(status):
"""Test is_saved property returns False for error statuses."""
recording = RecordingFactory(status=status)
assert recording.is_saved is False
+4 -1
View File
@@ -11,10 +11,12 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from livekit.api import AccessToken, VideoGrants
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
def generate_color(identity: str) -> str:
@@ -125,6 +127,7 @@ def generate_s3_authorization_headers(key):
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
+58 -318
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,32 +17,28 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: core/admin.py:39
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: core/admin.py:51
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:60
msgid "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/authentication/backends.py:77
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
@@ -57,412 +53,156 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
#: core/models.py:49
msgid "Aborted"
msgstr ""
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
#: core/models.py:52
msgid "Notification succeeded"
msgstr ""
#: core/models.py:79
msgid "SCREEN_RECORDING"
msgstr ""
#: core/models.py:80
msgid "TRANSCRIPT"
msgstr ""
#: core/models.py:86
msgid "Public Access"
msgstr ""
#: core/models.py:87
msgid "Trusted Access"
msgstr ""
#: core/models.py:88
msgid "Restricted Access"
msgstr ""
#: core/models.py:100
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:101
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:107
#: core/models.py:48
msgid "created on"
msgstr ""
#: core/models.py:108
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:113
#: core/models.py:54
msgid "updated on"
msgstr ""
#: core/models.py:114
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:134
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:140
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:142
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:150
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:155
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
#: core/models.py:159
msgid "short name"
msgstr ""
#: core/models.py:165
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:166
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:172
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:175
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:177
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:180
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:182
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:185
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:188
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:201
#: core/models.py:138
msgid "user"
msgstr ""
#: core/models.py:202
#: core/models.py:139
msgid "users"
msgstr ""
#: core/models.py:261
msgid "Resource"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:262
msgid "Resources"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:316
msgid "Resource access"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:317
msgid "Resource accesses"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:323
msgid "Resource access with this User and Resource already exists."
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:379
msgid "Visio room configuration"
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:380
msgid "Values for Visio parameters to configure the room."
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:386 core/models.py:506
msgid "Room"
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:387
msgid "Rooms"
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:517
msgid "Worker ID"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:519
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:527
msgid "Recording mode"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:528
msgid "Defines the mode of recording being called."
msgstr ""
#: core/models.py:534
msgid "Recording"
msgstr ""
#: core/models.py:535
msgid "Recordings"
msgstr ""
#: core/models.py:592
msgid "Recording/user relation"
msgstr ""
#: core/models.py:593
msgid "Recording/user relations"
msgstr ""
#: core/models.py:599
msgid "This user is already in this recording."
msgstr ""
#: core/models.py:605
msgid "This team is already in this recording."
msgstr ""
#: core/models.py:611
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
msgid "Your recording is ready"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
#: meet/settings.py:134
msgid "English"
msgstr ""
#: meet/settings.py:162
#: meet/settings.py:135
msgid "French"
msgstr ""
+58 -318
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,32 +17,28 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: core/admin.py:39
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: core/admin.py:51
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:60
msgid "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/authentication/backends.py:77
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
@@ -57,412 +53,156 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
#: core/models.py:49
msgid "Aborted"
msgstr ""
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
#: core/models.py:52
msgid "Notification succeeded"
msgstr ""
#: core/models.py:79
msgid "SCREEN_RECORDING"
msgstr ""
#: core/models.py:80
msgid "TRANSCRIPT"
msgstr ""
#: core/models.py:86
msgid "Public Access"
msgstr ""
#: core/models.py:87
msgid "Trusted Access"
msgstr ""
#: core/models.py:88
msgid "Restricted Access"
msgstr ""
#: core/models.py:100
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:101
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:107
#: core/models.py:48
msgid "created on"
msgstr ""
#: core/models.py:108
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:113
#: core/models.py:54
msgid "updated on"
msgstr ""
#: core/models.py:114
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:134
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:140
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:142
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:150
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:155
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
#: core/models.py:159
msgid "short name"
msgstr ""
#: core/models.py:165
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:166
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:172
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:175
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:177
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:180
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:182
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:185
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:188
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:201
#: core/models.py:138
msgid "user"
msgstr ""
#: core/models.py:202
#: core/models.py:139
msgid "users"
msgstr ""
#: core/models.py:261
msgid "Resource"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:262
msgid "Resources"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:316
msgid "Resource access"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:317
msgid "Resource accesses"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:323
msgid "Resource access with this User and Resource already exists."
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:379
msgid "Visio room configuration"
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:380
msgid "Values for Visio parameters to configure the room."
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:386 core/models.py:506
msgid "Room"
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:387
msgid "Rooms"
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:517
msgid "Worker ID"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:519
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:527
msgid "Recording mode"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:528
msgid "Defines the mode of recording being called."
msgstr ""
#: core/models.py:534
msgid "Recording"
msgstr ""
#: core/models.py:535
msgid "Recordings"
msgstr ""
#: core/models.py:592
msgid "Recording/user relation"
msgstr ""
#: core/models.py:593
msgid "Recording/user relations"
msgstr ""
#: core/models.py:599
msgid "This user is already in this recording."
msgstr ""
#: core/models.py:605
msgid "This team is already in this recording."
msgstr ""
#: core/models.py:611
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
msgid "Your recording is ready"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
#: meet/settings.py:134
msgid "English"
msgstr ""
#: meet/settings.py:162
#: meet/settings.py:135
msgid "French"
msgstr ""
-8
View File
@@ -307,9 +307,6 @@ class Base(Configuration):
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
}
# Mail
@@ -324,8 +321,6 @@ class Base(Configuration):
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_DOMAIN = values.Value(None)
EMAIL_APP_BASE_URL = values.Value(None)
AUTH_USER_MODEL = "core.User"
@@ -489,9 +484,6 @@ class Base(Configuration):
SUMMARY_SERVICE_API_TOKEN = values.Value(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
+3 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.18"
version = "0.1.17"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.24",
"boto3==1.37.18",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -53,6 +53,7 @@ dependencies = [
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.24.1",
"url-normalize==1.4.3",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.18",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.18",
"version": "0.1.17",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.18",
"version": "0.1.17",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
-6
View File
@@ -397,12 +397,6 @@ const config: Config = {
lineHeight: '1.25rem',
},
},
xs: {
value: {
fontSize: '0.825rem',
lineHeight: '1.15rem',
},
},
badge: {
value: {
fontSize: '0.75rem',
-11
View File
@@ -1,11 +0,0 @@
export const mediaUrl = (path: string) => {
const origin =
import.meta.env.VITE_API_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
// Remove leading/trailing slashes from origin/path if it exists
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/media/${sanitizedPath}`
}
+1 -2
View File
@@ -1,7 +1,7 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export interface ApiConfig {
analytics?: {
@@ -12,7 +12,6 @@ export interface ApiConfig {
id: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
@@ -1,5 +0,0 @@
export enum FeatureFlags {
Transcript = 'transcription-summary',
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
}
+2 -17
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
import { useEffect, useMemo } from 'react'
import { useEffect } from 'react'
import {
startAnalyticsSession,
terminateAnalyticsSession,
@@ -12,7 +12,6 @@ import {
terminateSupportSession,
} from '@/features/support/hooks/useSupport'
import { logoutUrl } from '../utils/logoutUrl'
import { useConfig } from '@/api/useConfig'
/**
* returns info about currently logged-in user
@@ -24,24 +23,11 @@ export const useUser = (
fetchUserOptions?: Parameters<typeof fetchUser>[0]
} = {}
) => {
const { data, isLoading } = useConfig()
const options = useMemo(() => {
if (!data || data?.is_silent_login_enabled !== true) {
return {
...opts,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts])
const query = useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [keys.user],
queryFn: () => fetchUser(options),
queryFn: () => fetchUser(opts.fetchUserOptions),
staleTime: Infinity,
enabled: !isLoading,
})
useEffect(() => {
@@ -65,7 +51,6 @@ export const useUser = (
refetch: query.refetch,
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
isLoggedIn,
isLoading: query.isLoading,
logout,
}
}
@@ -10,14 +10,10 @@ import { usePrevious } from '@/hooks/usePrevious'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications'
export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => {
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', {
keyPrefix: 'waitingParticipants',
})
@@ -41,9 +37,6 @@ export const WaitingParticipantNotification = () => {
!isParticipantsOpen
) {
setShowQuickActionsMessage(true)
triggerNotificationSound(NotificationType.ParticipantJoined)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
@@ -55,12 +48,7 @@ export const WaitingParticipantNotification = () => {
// Hide notification when the participant count changes
setShowQuickActionsMessage(false)
}
}, [
waitingParticipants,
prevWaitingParticipant,
isParticipantsOpen,
triggerNotificationSound,
])
}, [waitingParticipants, prevWaitingParticipant, isParticipantsOpen])
useEffect(() => {
// This cleanup function will only run when the component unmounts
@@ -1,37 +0,0 @@
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '../NotificationType'
import { NotificationPayload } from '../NotificationPayload'
export const useNotifyParticipants = () => {
const room = useRoomContext()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const notifyParticipants = async <T extends Record<string, any>>(options: {
type: NotificationType
destinationIdentities?: string[]
additionalData?: T
reliable?: boolean
}): Promise<void> => {
const {
type,
destinationIdentities,
additionalData = {} as T,
reliable = true,
} = options
const payload: NotificationPayload & T = {
type,
...additionalData,
}
const encoder = new TextEncoder()
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable,
destinationIdentities,
})
}
return { notifyParticipants }
}
@@ -1,2 +0,0 @@
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
export { NotificationType } from './NotificationType'
@@ -1,16 +1,19 @@
import { fetchApi } from '@/api/fetchApi'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode, RecordingStatus } from '@/features/recording'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export type RecordingApi = {
id: string
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
created_at: string
key: string
mode: RecordingMode
status: RecordingStatus
}
export const fetchRecording = ({ recordingId }: { recordingId?: string }) => {
export const fetchRecording = ({
recordingId,
}: {
roomId: string
username?: string
}) => {
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
}
@@ -1,245 +0,0 @@
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
import { FeatureFlags } from '@/features/analytics/enums'
import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
export const TranscriptSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const recordingSnap = useSnapshot(recordingStore)
const { notifyParticipants } = useNotifyParticipants()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStarting: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING,
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
}
}, [recordingSnap])
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
await notifyParticipants({
type: NotificationType.TranscriptionStopped,
})
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={thirdSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{!hasTranscriptAccess ? (
<>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<LinkButton
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{t('beta.button')}
</LinkButton>
</>
) : (
<>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{statuses.isStarting ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
</>
)}
</Div>
)
}
@@ -1,22 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
export const useHasRecordingAccess = (
mode: RecordingMode,
featureFlag: FeatureFlags
) => {
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isRecordingModeEnabled
)
}
@@ -1,22 +0,0 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RecordingMode } from '@/features/recording'
export const useIsRecordingActive = (mode: RecordingMode) => {
const recordingSnap = useSnapshot(recordingStore)
switch (mode) {
case RecordingMode.Transcript:
return [
RecordingStatus.TRANSCRIPT_STARTED,
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
].includes(recordingSnap.status)
case RecordingMode.ScreenRecording:
return [
RecordingStatus.SCREEN_RECORDING_STARTED,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
].includes(recordingSnap.status)
}
}
+1 -18
View File
@@ -1,18 +1 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
// api
export { useStartRecording } from './api/startRecording'
export { useStopRecording } from './api/stopRecording'
export { RecordingMode, RecordingStatus } from './types'
// components
export { RecordingStateToast } from './components/RecordingStateToast'
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
// routes
export { RecordingDownload as RecordingDownloadRoute } from './routes/RecordingDownload'
export { Recording as RecordingRoute } from './routes/Recording'
@@ -0,0 +1,107 @@
import { Center, VStack } from '@/styled-system/jsx'
import { Bold, Button, Field, H, Input, LinkButton, Text } from '@/primitives'
import { Screen } from '@/layout/Screen.tsx'
import { useParams } from 'wouter'
import { fetchRecording } from '../api/fetchRecording'
import { useQuery } from '@tanstack/react-query'
import { Spinner } from '@/primitives/Spinner'
import { UserAware, useUser } from '@/features/auth'
import { CenteredContent } from '@/layout/CenteredContent.tsx'
import { useState } from 'react'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
import { RecordingMode } from '@/features/rooms/api/startRecording.ts'
function formatDate(date, format = 'YYYY-MM-DD') {
// Accept Date object or create one from string
const dateObj = date instanceof Date ? date : new Date(date)
// Check if the date is valid
if (isNaN(dateObj.getTime())) {
return 'Invalid Date'
}
const year = dateObj.getFullYear()
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
const day = dateObj.getDate()
const hours = dateObj.getHours()
const minutes = dateObj.getMinutes()
const seconds = dateObj.getSeconds()
// Pad numbers with leading zeros
const pad = (num) => String(num).padStart(2, '0')
// Replace format tokens with actual values
return format
.replace('YYYY', year)
.replace('MM', pad(month))
.replace('DD', pad(day))
.replace('HH', pad(hours))
.replace('mm', pad(minutes))
.replace('ss', pad(seconds))
}
export const Recording = () => {
const { recordingId } = useParams()
const { isLoggedIn } = useUser()
const { data, isLoading, isError } = useQuery({
queryKey: ['recording', recordingId],
queryFn: () => fetchRecording({ recordingId }),
retry: false,
})
if (isLoading) {
return (
<Screen layout="centered" footer={false}>
<CenteredContent>
<Spinner />
</CenteredContent>
</Screen>
)
}
if (isError || !isLoggedIn) {
return (
<Screen layout="centered" footer={false}>
<CenteredContent title={'lien invalide'} withBackButton>
<Text centered>
Vous ne pouvez pas accéder à ce lien d'enregistrement
</Text>
</CenteredContent>
</Screen>
)
}
return (
<UserAware>
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<img
src={fourthSlide}
alt={''}
className={css({
maxHeight: '309px',
})}
/>
<H lvl={1} centered>
Votre enregistrement est prêt !
</H>
<Text centered margin="md">
Enregistrement de la réunion <b>{data.room.name}</b> du{' '}
{formatDate(data.created_at, 'YYYY-MM-DD HH:mm')}. <br /> Cet
enregistrement est disponible <u>pendant 7 jours</u>.
</Text>
<LinkButton
href={`https://meet.127.0.0.1.nip.io/media/recordings/${data.id}.${data.mode == RecordingMode.ScreenRecording ? 'mp4' : 'ogg'}`}
download={`${data.room.name}-${formatDate(data.created_at)}`}
>
Télécharger
</LinkButton>
</VStack>
</Center>
</Screen>
</UserAware>
)
}
@@ -1,89 +0,0 @@
import { useParams } from 'wouter'
import { useQuery } from '@tanstack/react-query'
import { Center, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { mediaUrl } from '@/api/mediaUrl'
import { UserAware, useUser } from '@/features/auth'
import { Screen } from '@/layout/Screen'
import { H, LinkButton, Text } from '@/primitives'
import { formatDate } from '@/utils/formatDate'
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording'
export const RecordingDownload = () => {
const { t } = useTranslation('recording')
const { recordingId } = useParams()
const { isLoggedIn, isLoading: isAuthLoading } = useUser()
const { data, isLoading, isError } = useQuery({
queryKey: ['recording', recordingId],
queryFn: () => fetchRecording({ recordingId }),
retry: false,
enabled: !!recordingId,
})
if (isLoading || !data || isAuthLoading) {
return <LoadingScreen />
}
if (!isLoggedIn) {
return (
<ErrorScreen
title={t('authentication.title')}
body={t('authentication.body')}
/>
)
}
if (isError) {
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
}
if (
data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed
) {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
}
return (
<UserAware>
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<img
src={fourthSlide}
alt={''}
className={css({
maxHeight: '309px',
})}
/>
<H lvl={1} centered>
{t('success.title')}
</H>
<Text centered margin="md" wrap={'balance'}>
<span
dangerouslySetInnerHTML={{
__html: t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
}),
}}
/>
</Text>
<LinkButton
href={mediaUrl(data.key)}
download={`${data.room.name}-${formatDate(data.created_at)}`}
>
{t('success.button')}
</LinkButton>
</VStack>
</Center>
</Screen>
</UserAware>
)
}
@@ -1,15 +0,0 @@
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
export enum RecordingStatus {
Initiated = 'initiated',
Active = 'active',
Stopped = 'stopped',
Saved = 'saved',
Aborted = 'aborted',
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded',
}
@@ -1,8 +1,12 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode } from '../types'
import { ApiRoom } from './ApiRoom'
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
export interface StartRecordingParams {
id: string
@@ -1,7 +1,7 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { ApiRoom } from './ApiRoom'
export interface StopRecordingParams {
id: string
@@ -3,15 +3,25 @@ import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
export const useMuteParticipant = () => {
const data = useRoomData()
const room = useRoomContext()
const { notifyParticipants } = useNotifyParticipants()
const notifyParticipant = async (participant: Participant) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ParticipantMuted,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [participant.identity],
})
}
const muteParticipant = async (participant: Participant) => {
if (!data || !data?.livekit) {
@@ -43,10 +53,7 @@ export const useMuteParticipant = () => {
}
)
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
await notifyParticipant(participant)
return response
} catch (error) {
@@ -1,75 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<VStack alignItems="start">
<Text
as="h3"
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{t('roomInformation.title')}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
</Text>
<Button
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
}}
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copy')}
</>
)}
</Button>
</VStack>
</Div>
)
}
@@ -9,42 +9,15 @@ import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RiRecordCircleLine } from '@remixicon/react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
keyPrefix: 'recordingToast',
})
const room = useRoomContext()
const { openTranscript, openScreenRecording } = useSidePanel()
const recordingSnap = useSnapshot(recordingStore)
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
useEffect(() => {
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
recordingStore.status = RecordingStatus.ANY_STARTED
@@ -125,12 +98,6 @@ export const RecordingStateToast = () => {
if (!key) return
const isStarted = key?.includes('started')
const hasScreenRecordingAccessAndActive =
isScreenRecordingActive && hasScreenRecordingAccess
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<div
className={css({
@@ -140,7 +107,7 @@ export const RecordingStateToast = () => {
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
backgroundColor: 'primaryDark.100',
borderColor: 'white',
border: '1px solid',
color: 'white',
@@ -148,51 +115,15 @@ export const RecordingStateToast = () => {
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
<Spinner size={20} variant="dark" />
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
</div>
)
}
@@ -0,0 +1,166 @@
import { A, Button, Div, Text } from '@/primitives'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
import {
useIsScreenRecordingStarted,
useIsTranscriptStarted,
} from '../hooks/useIsRecordingStarted'
export const ScreenRecording = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const isScreenRecordingStarted = useIsScreenRecordingStarted()
const isTranscriptStarted = useIsTranscriptStarted()
const room = useRoomContext()
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const notifyParticipant = async (status: NotificationType) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: status,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
})
}
const handleScreenRecording = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
await notifyParticipant(NotificationType.ScreenRecordingStopped)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
} else {
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
})
await notifyParticipant(NotificationType.ScreenRecordingStarted)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() => isLoading || isRecordingTransitioning || isTranscriptStarted,
[isLoading, isRecordingTransitioning, isTranscriptStarted]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={fourthSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{isScreenRecordingStarted ? (
<>
<Text>{t('stop.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
<Text>{t('start.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{t('start.button')}
</Button>
</>
)}
</Div>
)
}
@@ -12,7 +12,6 @@ import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
type StyledSidePanelProps = {
title: string
@@ -134,7 +133,6 @@ export const SidePanel = () => {
isSidePanelOpen,
isToolsOpen,
isAdminOpen,
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
} = useSidePanel()
@@ -169,9 +167,6 @@ export const SidePanel = () => {
<Panel isOpen={isAdminOpen}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
<Info />
</Panel>
</StyledSidePanel>
)
}
@@ -4,25 +4,18 @@ import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
import { ReactNode } from 'react'
import { Transcript } from './Transcript'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import {
useIsRecordingModeEnabled,
RecordingMode,
useHasRecordingAccess,
TranscriptSidePanel,
ScreenRecordingSidePanel,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { ScreenRecording } from '@/features/rooms/livekit/components/ScreenRecording'
import { useIsRecordingEnabled } from '../hooks/useIsRecordingEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export interface ToolsButtonProps {
icon: ReactNode
title: string
description: string
onPress: () => void
isBetaFeature?: boolean
isActive?: boolean
}
const ToolButton = ({
@@ -30,8 +23,6 @@ const ToolButton = ({
title,
description,
onPress,
isBetaFeature = false,
isActive = false,
}: ToolsButtonProps) => {
return (
<RACButton
@@ -62,47 +53,13 @@ const ToolButton = ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
})}
>
{icon}
{isBetaFeature && (
<div
className={css({
position: 'absolute',
backgroundColor: 'primary.50',
color: 'primary.800',
fontSize: '12px',
fontWeight: 500,
borderRadius: '4px',
paddingX: '4px',
paddingBottom: '1px',
bottom: -8,
right: -8,
})}
>
BETA
</div>
)}
</div>
<div>
<Text
margin={false}
as="h3"
className={css({ display: 'flex', gap: 0.25 })}
>
<Text margin={false} as="h3">
{title}
{isActive && (
<div
className={css({
backgroundColor: 'primary.500',
height: '10px',
width: '10px',
marginTop: '5px',
borderRadius: '100%',
})}
/>
)}
</Text>
<Text as="p" variant="smNote" wrap="pretty">
{description}
@@ -115,28 +72,18 @@ const ToolButton = ({
export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
// FIXME - use settings returned by backend
const isRecordingEnabled = true
switch (activeSubPanelId) {
case SubPanelId.TRANSCRIPT:
return <TranscriptSidePanel />
return <Transcript />
case SubPanelId.SCREEN_RECORDING:
return <ScreenRecordingSidePanel />
return <ScreenRecording />
default:
break
}
@@ -171,18 +118,14 @@ export const Tools = () => {
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isBetaFeature
isActive={isTranscriptActive}
/>
)}
{hasScreenRecordingAccess && (
{isRecordingEnabled && (
<ToolButton
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isBetaFeature
isActive={isScreenRecordingActive}
/>
)}
</Div>
@@ -1,56 +1,46 @@
import { A, Button, Div, H, Text } from '@/primitives'
import { A, Button, Div, LinkButton, Text } from '@/primitives'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
useStopRecording,
} from '@/features/recording'
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
import { useIsRecordingTransitioning } from '@/features/recording'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
import {
useIsScreenRecordingStarted,
useIsTranscriptStarted,
} from '../hooks/useIsRecordingStarted'
export const ScreenRecordingSidePanel = () => {
export const Transcript = () => {
const [isLoading, setIsLoading] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const { notifyParticipants } = useNotifyParticipants()
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const hasTranscriptAccess = useHasTranscriptAccess()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStarting:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING,
isStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStopping:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
}
}, [recordingSnap])
const isScreenRecordingStarted = useIsScreenRecordingStarted()
const isTranscriptStarted = useIsTranscriptStarted()
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
@@ -62,7 +52,18 @@ export const ScreenRecordingSidePanel = () => {
}
}, [room])
const handleScreenRecording = async () => {
const notifyParticipant = async (status: NotificationType) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: status,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
})
}
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
@@ -71,20 +72,12 @@ export const ScreenRecordingSidePanel = () => {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
await notifyParticipants({
type: NotificationType.ScreenRecordingStopped,
})
await notifyParticipant(NotificationType.TranscriptionStopped)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
} else {
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
})
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {})
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
await notifyParticipant(NotificationType.TranscriptionStarted)
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -93,9 +86,8 @@ export const ScreenRecordingSidePanel = () => {
}
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
() => isLoading || isRecordingTransitioning || isScreenRecordingStarted,
[isLoading, isRecordingTransitioning, isScreenRecordingStarted]
)
return (
@@ -108,19 +100,16 @@ export const ScreenRecordingSidePanel = () => {
alignItems="center"
>
<img
src={fourthSlide}
src={thirdSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{statuses.isStarted ? (
{!hasTranscriptAccess ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
@@ -131,45 +120,50 @@ export const ScreenRecordingSidePanel = () => {
marginTop: '0.25rem',
})}
>
{t('stop.body')}
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
<LinkButton
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{t('stop.button')}
</Button>
{t('beta.button')}
</LinkButton>
</>
) : (
<>
{statuses.isStopping ? (
{isTranscriptStarted ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text>{t('stop.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
{t('stop.body')}
</Text>
<Spinner />
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text>{t('start.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
@@ -182,25 +176,18 @@ export const ScreenRecordingSidePanel = () => {
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
onPress={() => handleTranscript()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{statuses.isStarting ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
{t('start.button')}
</Button>
</>
)}
@@ -1,41 +0,0 @@
import { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const InfoToggle = ({
onPress,
...props
}: Partial<ToggleButtonProps>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
onPress={(e) => {
toggleInfo()
onPress?.(e)
}}
data-attr={`controls-info-${tooltipLabel}`}
{...props}
>
<RiInformationLine />
</ToggleButton>
</div>
)
}
@@ -33,20 +33,19 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
const label = isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
return (
<>
<Button
square
variant="greyscale"
size="sm"
tooltip={label}
aria-label={label}
tooltip={
isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
}
isDisabled={isMuted}
onPress={() =>
!isMuted && isLocal(participant)
@@ -56,7 +55,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} aria-hidden={true} />
<RiMicOffFill color={'gray'} />
) : (
<RiMicFill
className={css({
@@ -65,7 +64,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
? 'pulse_background 800ms infinite'
: undefined,
})}
aria-hidden={true}
/>
)}
</Button>
@@ -1,9 +1,8 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { FeatureFlags } from '@/features/analytics/enums'
export const useHasFaceLandmarksAccess = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const featureEnabled = useFeatureFlagEnabled('face-landmarks')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return featureEnabled || !isAnalyticsEnabled
@@ -0,0 +1,20 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasScreenRecordingAccess = () => {
const featureEnabled = useFeatureFlagEnabled('screen-recording')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isScreenRecordingEnabled = useIsRecordingEnabled(
RecordingMode.ScreenRecording
)
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isScreenRecordingEnabled
)
}
@@ -0,0 +1,18 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isTranscriptEnabled
)
}
@@ -1,7 +1,7 @@
import { RecordingMode } from '../types'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
export const useIsRecordingEnabled = (mode: RecordingMode) => {
const { data } = useConfig()
return (
@@ -0,0 +1,12 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsScreenRecordingStarted = () => {
const recordingSnap = useSnapshot(recordingStore)
return recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED
}
export const useIsTranscriptStarted = () => {
const recordingSnap = useSnapshot(recordingStore)
return recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED
}
@@ -7,7 +7,6 @@ export enum PanelId {
CHAT = 'chat',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
}
export enum SubPanelId {
@@ -25,7 +24,6 @@ export const useSidePanel = () => {
const isChatOpen = activePanelId == PanelId.CHAT
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isInfoOpen = activePanelId == PanelId.INFO
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
@@ -56,11 +54,6 @@ export const useSidePanel = () => {
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
@@ -79,7 +72,6 @@ export const useSidePanel = () => {
toggleEffects,
toggleTools,
toggleAdmin,
toggleInfo,
openTranscript,
openScreenRecording,
isSubPanelOpen,
@@ -89,7 +81,6 @@ export const useSidePanel = () => {
isSidePanelOpen,
isToolsOpen,
isAdminOpen,
isInfoOpen,
isTranscriptOpen,
isScreenRecordingOpen,
}
@@ -2,7 +2,6 @@ import { css } from '@/styled-system/css'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { InfoToggle } from '../../components/controls/InfoToggle'
import { AdminToggle } from '../../components/AdminToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react'
@@ -19,7 +18,6 @@ const NavigationControls = ({
tooltipType = 'instant',
}: Partial<ToggleButtonProps>) => (
<>
<InfoToggle onPress={onPress} tooltipType={tooltipType} />
<ChatToggle onPress={onPress} tooltipType={tooltipType} />
<ParticipantsToggle onPress={onPress} tooltipType={tooltipType} />
<ToolsToggle onPress={onPress} tooltipType={tooltipType} />
@@ -28,7 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '@/features/recording'
import { RecordingStateToast } from '../components/RecordingStateToast'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
@@ -77,21 +77,8 @@ export const CreateMeetingButton = () => {
if (isPending) {
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<div>
<Spinner size={34} />
<Button
variant="quaternaryText"
square
icon={<RiCloseLine />}
onPress={resetState}
aria-label={t('resetLabel')}
/>
</div>
)
}
@@ -1,19 +0,0 @@
{
"error": {
"title": "",
"body": ""
},
"authentication": {
"title": "",
"body": ""
},
"unsaved": {
"title": "",
"body": ""
},
"success": {
"title": "",
"body": "",
"button": ""
}
}
+3 -46
View File
@@ -91,10 +91,6 @@
}
}
},
"info": {
"open": "",
"closed": ""
},
"hand": {
"raise": "",
"lower": ""
@@ -173,20 +169,16 @@
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
"tools": ""
},
"content": {
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
"tools": ""
},
"closeButton": ""
},
@@ -200,20 +192,6 @@
"transcript": {
"title": "",
"body": ""
},
"screenRecording": {
"title": "",
"body": ""
}
}
},
"info": {
"roomInformation": {
"title": "",
"button": {
"ariaLabel": "",
"copy": "",
"copied": ""
}
}
},
@@ -229,33 +207,12 @@
"body": "",
"button": ""
},
"stopping": {
"heading": "",
"body": ""
},
"beta": {
"heading": "",
"body": "",
"button": ""
}
},
"screenRecording": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
},
"stopping": {
"heading": "",
"body": ""
},
"stop": {
"heading": "",
"body": "",
"button": ""
}
},
"admin": {
"description": "",
"access": {
@@ -335,7 +292,7 @@
}
}
},
"recordingBadge": {
"recordingToast": {
"transcript": {
"started": "",
"starting": "",
@@ -1,19 +0,0 @@
{
"error": {
"title": "Recording unavailable",
"body": "This recording could not be found or was deleted after its 7-day validity period."
},
"authentication": {
"title": "Authentication required",
"body": "Please log in to access this recording."
},
"unsaved": {
"title": "Download Unavailable",
"body": "This recording is not ready for download yet. Please try again later."
},
"success": {
"title": "Your recording is ready!",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"button": "Download"
}
}
+3 -29
View File
@@ -90,10 +90,6 @@
}
}
},
"info": {
"open": "Hide information",
"closed": "Show information"
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
@@ -174,8 +170,7 @@
"transcript": "Transcription",
"screenRecording": "Recording",
"admin": "Admin settings",
"tools": "More tools",
"info": "Meeting information"
"tools": "More tools"
},
"content": {
"participants": "participants",
@@ -184,8 +179,7 @@
"transcript": "transcription",
"screenRecording": "recording",
"admin": "admin settings",
"tools": "more tools",
"info": "meeting information"
"tools": "more tools"
},
"closeButton": "Hide {{content}}"
},
@@ -206,22 +200,11 @@
}
}
},
"info": {
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
}
}
},
"transcript": {
"start": {
"heading": "Transcribe this call",
"body": "Automatically transcribe this call and receive the summary in Docs.",
"button": "Start transcription",
"loading": "Transcription starting",
"linkMore": "Learn more"
},
"stop": {
@@ -229,10 +212,6 @@
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
"button": "Stop transcription"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"beta": {
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
@@ -244,13 +223,8 @@
"heading": "Record this call",
"body": "Record this call to watch it later and receive the video recording by email.",
"button": "Start recording",
"loading": "Recording starting",
"linkMore": "Learn more"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
@@ -336,7 +310,7 @@
}
}
},
"recordingStateToast": {
"recordingToast": {
"transcript": {
"started": "Transcribing",
"starting": "Transcription starting",
@@ -1,19 +0,0 @@
{
"error": {
"title": "Enregistrement indisponible",
"body": "Cet enregistrement est introuvable ou a été supprimé après sa période de validité de 7 jours."
},
"authentication": {
"title": "Authentification requise",
"body": "Veuillez vous connecter pour accéder à cet enregistrement."
},
"unsaved": {
"title": "Téléchargement indisponible",
"body": "Cet enregistrement n'est pas encore prêt à être téléchargé. Veuillez réessayer plus tard."
},
"success": {
"title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"button": "Télécharger"
}
}
+3 -29
View File
@@ -90,10 +90,6 @@
}
}
},
"info": {
"open": "Masquer les informations",
"closed": "Afficher les informations"
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
@@ -174,8 +170,7 @@
"transcript": "Transcription",
"screenRecording": "Enregistrement",
"admin": "Commandes de l'organisateur",
"tools": "Plus d'outils",
"info": "Informations sur la réunion"
"tools": "Plus d'outils"
},
"content": {
"participants": "les participants",
@@ -184,8 +179,7 @@
"transcript": "transcription",
"screenRecording": "enregistrement",
"admin": "commandes de l'organisateur",
"tools": "plus d'outils",
"info": "informations sur la réunion"
"tools": "plus d'outils"
},
"closeButton": "Masquer {{content}}"
},
@@ -206,22 +200,11 @@
}
}
},
"info": {
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
}
}
},
"transcript": {
"start": {
"heading": "Transcrire cet appel",
"body": "Transcrivez cet appel automatiquement et recevez le compte rendu dans Docs.",
"button": "Démarrer la transcription",
"loading": "Démarrage de la transcription",
"linkMore": "En savoir plus"
},
"stop": {
@@ -229,10 +212,6 @@
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
"button": "Arrêter la transcription"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"beta": {
"heading": "Devenez beta testeur",
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
@@ -244,13 +223,8 @@
"heading": "Enregistrer cet appel",
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
"button": "Démarrer l'enregistrement",
"loading": "Démarrage de l'enregistrement",
"linkMore": "En savoir plus"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
@@ -336,7 +310,7 @@
}
}
},
"recordingStateToast": {
"recordingToast": {
"transcript": {
"started": "Transcription en cours",
"starting": "Démarrage de la transcription",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"createMeeting": {
"createButton": "Créer un lien Visio",
"createButton": "Créer un lien",
"joinButton": "Participer avec Visio",
"copyLinkTooltip": "Copier le lien",
"resetLabel": "Réinitialiser",
@@ -1,19 +0,0 @@
{
"error": {
"title": "Opname niet beschikbaar",
"body": "Deze opname is niet gevonden of is verwijderd na de geldigheidsperiode van 7 dagen."
},
"authentication": {
"title": "Authenticatie vereist",
"body": "Log in om toegang te krijgen tot deze opname."
},
"unsaved": {
"title": "Download niet beschikbaar",
"body": "Deze opname is nog niet klaar om te worden gedownload. Probeer het later opnieuw."
},
"success": {
"title": "Je opname is klaar!",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"button": "Downloaden"
}
}
+3 -29
View File
@@ -90,10 +90,6 @@
}
}
},
"info": {
"open": "Informatie verbergen",
"closed": "Informatie tonen"
},
"hand": {
"raise": "Hand opsteken",
"lower": "Hand laten zakken"
@@ -174,8 +170,7 @@
"transcript": "Transcriptie",
"screenRecording": "Schermopname",
"admin": "Beheerdersbediening",
"tools": "Meer tools",
"info": "Vergaderinformatie"
"tools": "Meer tools"
},
"content": {
"participants": "deelnemers",
@@ -184,8 +179,7 @@
"screenRecording": "transcriptie",
"transcript": "schermopname",
"admin": "beheerdersbediening",
"tools": "meer tools",
"info": "vergaderinformatie"
"tools": "meer tools"
},
"closeButton": "Verberg {{content}}"
},
@@ -206,22 +200,11 @@
}
}
},
"info": {
"roomInformation": {
"title": "Verbindingsinformatie",
"button": {
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
}
}
},
"transcript": {
"start": {
"heading": "Transcribeer dit gesprek",
"body": "Transcribeer dit gesprek automatisch en ontvang het verslag in Docs.",
"button": "Transcriptie starten",
"loading": "Transcriptie begint",
"linkMore": "Meer informatie"
},
"stop": {
@@ -229,10 +212,6 @@
"body": "De transcriptie van uw vergadering is bezig. U ontvangt het resultaat per e-mail zodra de vergadering is afgelopen.",
"button": "Transcriptie stoppen"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"beta": {
"heading": "Word betatester",
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
@@ -244,13 +223,8 @@
"heading": "Dit gesprek opnemen",
"body": "Neem dit gesprek op om het later terug te kijken. Je ontvangt de video-opname per e-mail.",
"button": "Opname starten",
"loading": "Opname gestarten",
"linkMore": "Meer informatie"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"stop": {
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
@@ -336,7 +310,7 @@
}
}
},
"recordingStateToast": {
"recordingToast": {
"transcript": {
"started": "Transcriptie bezig",
"starting": "Transcriptie begint",
+1 -1
View File
@@ -31,7 +31,7 @@ export const Spinner = ({
strokeDashoffset={0}
strokeLinecap="round"
className={css({
stroke: variant == 'light' ? 'primary.100' : 'transparent',
stroke: variant == 'light' ? 'primary.100' : 'primaryDark.100',
})}
style={{}}
/>
-4
View File
@@ -57,10 +57,6 @@ export const text = cva({
color: 'default.subtle-text',
textStyle: 'sm',
},
xsNote: {
color: 'default.subtle-text',
textStyle: 'xs',
},
inherits: {},
},
centered: {
+10 -11
View File
@@ -5,7 +5,7 @@ import { AccessibilityRoute } from '@/features/legalsTerms/Accessibility'
import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
import { RecordingDownloadRoute } from '@/features/recording'
import { DownloadRoute, RecordingRoute } from '@/features/recording'
export const routes: Record<
| 'home'
@@ -15,8 +15,7 @@ export const routes: Record<
| 'accessibility'
| 'termsOfService'
| 'sdkCreatePopup'
| 'sdkCreateButton'
| 'recordingDownload',
| 'sdkCreateButton',
{
name: RouteName
path: RegExp | string
@@ -36,6 +35,14 @@ export const routes: Record<
to: (roomId: string) => `/${roomId.trim()}`,
Component: RoomRoute,
},
recording: {
name: 'recording',
path: new RegExp(
`^[/]recording[/](?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`
),
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
Component: RecordingRoute,
},
feedback: {
name: 'feedback',
path: '/feedback',
@@ -66,14 +73,6 @@ export const routes: Record<
path: '/sdk/create-button',
Component: CreateMeetingButton,
},
recordingDownload: {
name: 'recordingDownload',
path: new RegExp(
`^[/]recording[/](?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`
),
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
Component: RecordingDownloadRoute,
},
}
export type RouteName = keyof typeof routes
-29
View File
@@ -1,29 +0,0 @@
export function formatDate(
date: Date | string | number,
format: string = 'YYYY-MM-DD'
): string {
const dateObj = date instanceof Date ? date : new Date(date)
if (isNaN(dateObj.getTime())) {
return 'Invalid Date'
}
const year = dateObj.getFullYear()
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
const day = dateObj.getDate()
const hours = dateObj.getHours()
const minutes = dateObj.getMinutes()
const seconds = dateObj.getSeconds()
const pad = (num: number): string => String(num).padStart(2, '0')
let result = format
result = result.replace(/YYYY/g, year.toString())
result = result.replace(/MM/g, pad(month))
result = result.replace(/DD/g, pad(day))
result = result.replace(/HH/g, pad(hours))
result = result.replace(/mm/g, pad(minutes))
result = result.replace(/ss/g, pad(seconds))
return result
}
@@ -19,8 +19,6 @@ backend:
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN: meet.127.0.0.1.nip.io
DJANGO_EMAIL_APP_BASE_URL: https://meet.127.0.0.1.nip.io
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
@@ -66,7 +64,6 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
@@ -34,9 +34,6 @@ backend:
DJANGO_EMAIL_USE_SSL: False
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN: meet.127.0.0.1.nip.io
DJANGO_EMAIL_APP_BASE_URL: https://meet.127.0.0.1.nip.io
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize
OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token
@@ -88,7 +85,6 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True
BREVO_API_KEY:
secretKeyRef:
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.4
version: 0.0.3
+28 -44
View File
@@ -4,50 +4,34 @@
### General configuration
| Name | Description | Value |
| ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` |
| `image.tag` | meet's container tag | `latest` |
| `image.pullPolicy` | Container image pull policy | `IfNotPresent` |
| `image.credentials.username` | Username for container registry authentication | |
| `image.credentials.password` | Password for container registry authentication | |
| `image.credentials.registry` | Registry url for which the credentials are specified | |
| `image.credentials.name` | Name of the generated secret for imagePullSecrets | |
| `nameOverride` | Override the chart name | `""` |
| `fullnameOverride` | Override the full application name | `""` |
| `ingress.enabled` | whether to enable the Ingress or not | `false` |
| `ingress.className` | IngressClass to use for the Ingress | `nil` |
| `ingress.host` | Host for the Ingress | `meet.example.com` |
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` |
| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` |
| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` |
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
| `ingressMedia.className` | IngressClass to use for the Ingress | `nil` |
| `ingressMedia.host` | Host for the Ingress | `meet.example.com` |
| `ingressMedia.path` | Path to use for the Ingress | `/media/(.*)` |
| `ingressMedia.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressMedia.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressMedia.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressMedia.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;` |
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
| `serviceMedia.port` | | `9000` |
| `serviceMedia.annotations` | | `{}` |
| Name | Description | Value |
| ------------------------------------------ | ---------------------------------------------------- | ---------------------- |
| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` |
| `image.tag` | meet's container tag | `latest` |
| `image.pullPolicy` | Container image pull policy | `IfNotPresent` |
| `image.credentials.username` | Username for container registry authentication | |
| `image.credentials.password` | Password for container registry authentication | |
| `image.credentials.registry` | Registry url for which the credentials are specified | |
| `image.credentials.name` | Name of the generated secret for imagePullSecrets | |
| `nameOverride` | Override the chart name | `""` |
| `fullnameOverride` | Override the full application name | `""` |
| `ingress.enabled` | whether to enable the Ingress or not | `false` |
| `ingress.className` | IngressClass to use for the Ingress | `nil` |
| `ingress.host` | Host for the Ingress | `meet.example.com` |
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` |
| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` |
| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` |
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
### backend
+1 -1
View File
@@ -80,4 +80,4 @@ spec:
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+3 -2
View File
@@ -69,6 +69,7 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
@@ -94,7 +95,6 @@ ingressMedia:
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
@@ -111,7 +111,6 @@ serviceMedia:
annotations: {}
## @section backend
backend:
@@ -588,3 +587,5 @@ celery:
## @param celery.pdb.enabled Enable pdb on celery
pdb:
enabled: false
+28 -39
View File
@@ -3,58 +3,46 @@
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section css-class="wrapper-logo">
<mj-section background-url="{% base64_static 'images/mail-header-background.png' %}" background-size="cover" background-repeat="no-repeat" background-position="0 -30px">
<mj-column>
<mj-image
align="center"
src="{{logo_img}}"
width="320px"
alt="{%trans 'Logo email' %}"
/>
<mj-image align="center" src="{% base64_static 'images/logo-suite-numerique.png' %}" width="250px" align="left" alt="{%trans 'La Suite Numérique' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<!-- Invitation message -->
<mj-text font-size="18px" color="#202124">
<p><a href="mailto:{{ sender_email }}">{{ sender_email }}</a> {% trans "invites you to join an ongoing video call" %}</p>
</mj-text>
<!-- Join button -->
<mj-button href="{{ room_url }}" background-color="#1A73E8" color="white" border-radius="4px" font-weight="bold" font-size="16px" padding="20px 0">
{% trans "JOIN THE CALL" %}
</mj-button>
<!-- Call URL -->
<mj-text align="center" color="#5F6368" font-size="14px" padding-top="15px">
<p>{{ room_link }}</p>
</mj-text>
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" padding="30px 0" />
<!-- Additional information -->
<mj-text font-size="14px">
<p>{% trans "If you can't click the button, copy and paste the URL into your browser to join the call." %}</p>
<p>{% trans "Invitation to join a team" %}</p>
</mj-text>
<!-- Quick tips -->
<mj-text padding-top="20px" font-size="14px">
<p>{% trans "Tips for a better experience:" %}</p>
<!-- Welcome Message -->
<mj-text>
<h1>{% blocktrans %}Welcome to <strong>Meet</strong>{% endblocktrans %}</h1>
</mj-text>
<mj-divider border-width="1px" border-style="solid" border-color="#DDDDDD" width="30%" align="left"/>
<mj-image src="{% base64_static 'images/logo.svg' %}" width="157px" align="left" alt="{%trans 'Logo' %}" />
<!-- Main Message -->
<mj-text>{% trans "We are delighted to welcome you to our community on Meet, your new companion to collaborate on documents efficiently, intuitively, and securely." %}</mj-text>
<mj-text>{% trans "Our application is designed to help you organize, collaborate, and manage permissions." %}</mj-text>
<mj-text>
{% trans "With Meet, you will be able to:" %}
<ul>
<li>{% trans "Use Chrome or Firefox for better call quality" %}</li>
<li>{% trans "Test your microphone and camera before joining" %}</li>
<li>{% trans "Make sure you have a stable internet connection" %}</li>
<li>{% trans "Create documents."%}</li>
<li>{% trans "Invite members of your document or community in just a few clicks."%}</li>
</ul>
</mj-text>
<mj-button href="//{{site.domain}}" background-color="#000091" color="white" padding-bottom="30px">
{% trans "Visit Meet"%}
</mj-button>
<mj-text>{% trans "We are confident that Meet will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
<mj-text>{% trans "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service." %}</mj-text>
<mj-text>{% trans "Once again, welcome aboard! We are eager to accompany you on you collaboration adventure." %}</mj-text>
<!-- Signature -->
<mj-text font-size="14px">
<p>
{% blocktrans %}
Thank you for using {{brandname}}.
{% endblocktrans %}
</p>
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "The La Suite Numérique Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
@@ -63,3 +51,4 @@
<mj-include path="./partial/footer.mjml" />
</mjml>
+2 -2
View File
@@ -21,7 +21,7 @@
<!-- Main Message -->
<mj-text>
{% blocktrans %}
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download.
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to view.
{% endblocktrans %}
</mj-text>
<mj-text>
@@ -64,4 +64,4 @@
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>
</mjml>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.18",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.18",
"version": "0.1.17",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.18",
"version": "0.1.17",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.18",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.18",
"version": "0.1.17",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.18",
"version": "0.1.17",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.18"
version = "0.1.17"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",