Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d126119ff0 | |||
| 83d04c499b | |||
| 41c1f41ed2 | |||
| dc06b55693 | |||
| 20aceb1932 | |||
| 3671f2a0dd | |||
| d74dd967af | |||
| 3a4f4e7016 | |||
| b7d964db56 | |||
| 88b7a7dc58 | |||
| baca9fc001 | |||
| c432524f2a | |||
| a079ceef71 | |||
| f0742a0978 | |||
| fc1b4d7fa7 | |||
| 46f26eb493 | |||
| ff09c3d969 | |||
| ba9d22f6c8 | |||
| 94e71ba15d | |||
| 9a1384b188 | |||
| 695ac47014 | |||
| b3c1deeb9c | |||
| c08c3efdbb | |||
| 468d09dc3b | |||
| a22d052f46 | |||
| 9734df9d5d | |||
| f596aae1e8 | |||
| 32956f495f | |||
| 69381a6c4b | |||
| d537a4449a | |||
| 50c9304afe |
@@ -135,6 +135,9 @@ 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
|
||||
@@ -152,6 +155,33 @@ 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
@@ -15,6 +15,37 @@ 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: .
|
||||
@@ -40,6 +71,7 @@ services:
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -73,6 +105,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
@@ -12,12 +12,19 @@ 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=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
|
||||
@@ -42,3 +49,6 @@ 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
|
||||
|
||||
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -20,7 +21,8 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core import enums, models, utils
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -544,6 +546,7 @@ class ResourceAccessViewSet(
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -617,3 +620,85 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"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
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
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
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
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."})
|
||||
|
||||
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)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
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
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{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.
|
||||
|
||||
@@ -18,6 +18,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .recording.enums import FileExtension
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -576,6 +578,26 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status == 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}"
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Enums related to recordings."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FileExtension(Enum):
|
||||
"""Enum for file extensions used in recordings."""
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
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 _
|
||||
|
||||
import requests
|
||||
|
||||
@@ -21,10 +26,7 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
return self._notify_user_by_email(recording)
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -33,6 +35,59 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
"""
|
||||
|
||||
owner_accesses = models.RecordingAccess.objects.select_related("user").filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
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}",
|
||||
}
|
||||
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/screen_recording.html", context)
|
||||
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as 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."""
|
||||
@@ -57,10 +112,8 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -89,7 +90,9 @@ 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="mp4")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.MP4.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
@@ -120,7 +123,9 @@ 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="ogg")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.OGG.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
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")
|
||||
owner = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
).user
|
||||
|
||||
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 f"notification to ['{owner.email}'] was not sent" in caplog.text
|
||||
@@ -22,6 +22,27 @@ 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"],
|
||||
@@ -58,6 +79,7 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"mode": recording.mode,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
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"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
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),
|
||||
"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),
|
||||
"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
|
||||
@@ -12,7 +12,8 @@ from core.factories import (
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
|
||||
from core.recording.enums import FileExtension
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -216,3 +217,71 @@ 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 status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
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
|
||||
|
||||
@@ -11,7 +11,9 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
|
||||
@@ -110,3 +112,33 @@ def generate_livekit_config(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our recording' metadata
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- 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,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+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,28 +17,32 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
#: core/api/serializers.py:60
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:71
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:27
|
||||
@@ -53,156 +57,412 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "created on"
|
||||
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
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:113
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:114
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:75
|
||||
#: core/models.py:134
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:81
|
||||
#: core/models.py:140
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:142
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:150
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:155
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:157
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:159
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:188
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:201
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:139
|
||||
#: core/models.py:202
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
#: core/models.py:261
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "description"
|
||||
#: core/models.py:262
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
#: core/models.py:316
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
#: core/models.py:317
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
#: core/models.py:323
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
#: core/models.py:379
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
#: core/models.py:380
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "Templates"
|
||||
#: core/models.py:386 core/models.py:506
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
#: core/models.py:387
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
#: core/models.py:517
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
#: 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."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
#: core/models.py:527
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
#: 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
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:134
|
||||
#: 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
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:135
|
||||
#: meet/settings.py:162
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+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,28 +17,32 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
#: core/api/serializers.py:60
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:71
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:27
|
||||
@@ -53,156 +57,412 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "created on"
|
||||
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
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:113
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:114
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:75
|
||||
#: core/models.py:134
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:81
|
||||
#: core/models.py:140
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:142
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:150
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:155
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:157
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:159
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:188
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:201
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:139
|
||||
#: core/models.py:202
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
#: core/models.py:261
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "description"
|
||||
#: core/models.py:262
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
#: core/models.py:316
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
#: core/models.py:317
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
#: core/models.py:323
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
#: core/models.py:379
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
#: core/models.py:380
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "Templates"
|
||||
#: core/models.py:386 core/models.py:506
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
#: core/models.py:387
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
#: core/models.py:517
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
#: 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."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
#: core/models.py:527
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
#: 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
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:134
|
||||
#: 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
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:135
|
||||
#: meet/settings.py:162
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
@@ -307,6 +307,9 @@ 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
|
||||
@@ -318,6 +321,10 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_DOMAIN = values.Value(None)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
|
||||
@@ -481,6 +488,9 @@ 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="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
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.18",
|
||||
"boto3==1.37.24",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
@@ -53,7 +53,6 @@ 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",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,7 +1,7 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -12,6 +12,7 @@ export interface ApiConfig {
|
||||
id: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
@@ -0,0 +1,5 @@
|
||||
export enum FeatureFlags {
|
||||
Transcript = 'transcription-summary',
|
||||
ScreenRecording = 'screen-recording',
|
||||
faceLandmarks = 'face-landmarks',
|
||||
}
|
||||
@@ -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 } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
startAnalyticsSession,
|
||||
terminateAnalyticsSession,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
terminateSupportSession,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
import { logoutUrl } from '../utils/logoutUrl'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
/**
|
||||
* returns info about currently logged-in user
|
||||
@@ -23,11 +24,24 @@ 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(opts.fetchUserOptions),
|
||||
queryFn: () => fetchUser(options),
|
||||
staleTime: Infinity,
|
||||
enabled: !isLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -90,6 +90,8 @@ export const MainNotificationToast = () => {
|
||||
break
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
|
||||
@@ -8,4 +8,6 @@ export enum NotificationType {
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<StyledToast>
|
||||
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||
<div {...contentProps}>{props.toast.content?.message}</div>
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
|
||||
+18
-3
@@ -1,19 +1,34 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export function ToastTranscript({ state, ...props }: ToastProps) {
|
||||
export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
const type = props.toast.content.type
|
||||
|
||||
const key = `recording${type == NotificationType.TranscriptionStarted ? 'Started' : 'Stopped'}`
|
||||
const key = useMemo(() => {
|
||||
switch (type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
return 'transcript.started'
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [type])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
@@ -9,7 +9,7 @@ import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastTranscript } from './ToastTranscript'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -39,7 +39,9 @@ const renderToast = (
|
||||
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return <ToastTranscript key={toast.key} toast={toast} state={state} />
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
|
||||
export { NotificationType } from './NotificationType'
|
||||
+2
-6
@@ -1,12 +1,8 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode } from '../types'
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export interface StopRecordingParams {
|
||||
id: string
|
||||
+47
-21
@@ -8,20 +8,22 @@ import { Text } from '@/primitives'
|
||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { TranscriptionStatus, transcriptionStore } from '@/stores/transcription'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const TranscriptStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording.transcript' })
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingBadge',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
|
||||
const transcriptionSnap = useSnapshot(transcriptionStore)
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
useEffect(() => {
|
||||
if (room.isRecording) {
|
||||
transcriptionStore.status = TranscriptionStatus.STARTED
|
||||
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [room.isRecording])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
@@ -34,10 +36,16 @@ export const TranscriptStateToast = () => {
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
transcriptionStore.status = TranscriptionStatus.STARTING
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
break
|
||||
case NotificationType.TranscriptionStopped:
|
||||
transcriptionStore.status = TranscriptionStatus.STOPPING
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
@@ -45,9 +53,17 @@ export const TranscriptStateToast = () => {
|
||||
}
|
||||
|
||||
const handleRecordingStatusChanged = (status: boolean) => {
|
||||
transcriptionStore.status = status
|
||||
? TranscriptionStatus.STARTED
|
||||
: TranscriptionStatus.STOPPED
|
||||
if (!status) {
|
||||
recordingStore.status = RecordingStatus.STOPPED
|
||||
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
|
||||
} else if (
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
) {
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
} else {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
@@ -57,20 +73,30 @@ export const TranscriptStateToast = () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
}, [room, recordingSnap])
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (transcriptionSnap.status) {
|
||||
case TranscriptionStatus.STOPPING:
|
||||
return 'stopping'
|
||||
case TranscriptionStatus.STARTING:
|
||||
return 'starting'
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STOPPING:
|
||||
return 'transcript.stopping'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STOPPING:
|
||||
return 'screenRecording.stopping'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return 'started'
|
||||
return
|
||||
}
|
||||
}, [transcriptionSnap])
|
||||
}, [recordingSnap])
|
||||
|
||||
if (transcriptionSnap.status == TranscriptionStatus.STOPPED) return
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<div
|
||||
+70
-68
@@ -1,43 +1,53 @@
|
||||
import { A, Button, Div, LinkButton, Text } from '@/primitives'
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
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'
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
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 {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import {
|
||||
TranscriptionStatus,
|
||||
transcriptionStore,
|
||||
} from '@/stores/transcription.ts'
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import {
|
||||
BETA_USERS_FORM_URL,
|
||||
CRISP_HELP_ARTICLE_TRANSCRIPT,
|
||||
} from '@/utils/constants'
|
||||
|
||||
export const Transcript = () => {
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const transcriptionSnap = useSnapshot(transcriptionStore)
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
isAnotherModeStarted:
|
||||
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
|
||||
isStarted:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
isStopping:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
@@ -49,18 +59,7 @@ export const Transcript = () => {
|
||||
}
|
||||
}, [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 handleTranscript = async () => {
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
@@ -69,12 +68,20 @@ export const Transcript = () => {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.TranscriptionStopped)
|
||||
transcriptionStore.status = TranscriptionStatus.STOPPING
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStopped,
|
||||
})
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
await notifyParticipant(NotificationType.TranscriptionStarted)
|
||||
transcriptionStore.status = TranscriptionStatus.STARTING
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
})
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
@@ -84,10 +91,8 @@ export const Transcript = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading ||
|
||||
transcriptionSnap.status === TranscriptionStatus.STARTING ||
|
||||
transcriptionSnap.status === TranscriptionStatus.STOPPING,
|
||||
[isLoading, transcriptionSnap]
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -100,16 +105,19 @@ export const Transcript = () => {
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={thirdSlide}
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
{!hasTranscriptAccess ? (
|
||||
|
||||
{statuses.isStarted ? (
|
||||
<>
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stop.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
@@ -120,50 +128,44 @@ export const Transcript = () => {
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('beta.body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<LinkButton
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="stop-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{room.isRecording ? (
|
||||
{statuses.isStopping ? (
|
||||
<>
|
||||
<Text>{t('stop.heading')}</Text>
|
||||
<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('stop.body')}
|
||||
{t('stopping.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('start.heading')}</Text>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('start.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
@@ -176,14 +178,14 @@ export const Transcript = () => {
|
||||
})}
|
||||
>
|
||||
{t('start.body')} <br />{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
@@ -0,0 +1,235 @@
|
||||
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'
|
||||
|
||||
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,
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RecordingMode } from '../types'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(mode)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsRecordingTransitioning = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const transitionalStates = [
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
]
|
||||
|
||||
return transitionalStates.includes(recordingSnap.status)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
export { useStopRecording } from './api/stopRecording'
|
||||
export { RecordingMode } from './types'
|
||||
|
||||
// components
|
||||
export { RecordingStateToast } from './components/RecordingStateToast'
|
||||
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
|
||||
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
@@ -3,25 +3,15 @@ import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
} from '@/features/notifications'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
const room = useRoomContext()
|
||||
|
||||
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 { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
@@ -53,7 +43,10 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
)
|
||||
|
||||
await notifyParticipant(participant)
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ParticipantMuted,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,10 +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 } from '@remixicon/react'
|
||||
import { useIsTranscriptEnabled } from '../hooks/useIsTranscriptEnabled'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
} from '@/features/recording'
|
||||
import {
|
||||
TranscriptSidePanel,
|
||||
ScreenRecordingSidePanel,
|
||||
} from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
@@ -68,12 +76,25 @@ const ToolButton = ({
|
||||
}
|
||||
|
||||
export const Tools = () => {
|
||||
const { openTranscript, isTranscriptOpen } = useSidePanel()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const isTranscriptEnabled = useIsTranscriptEnabled()
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
if (isTranscriptOpen && isTranscriptEnabled) {
|
||||
return <Transcript />
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
switch (activeSubPanelId) {
|
||||
case SubPanelId.TRANSCRIPT:
|
||||
return <TranscriptSidePanel />
|
||||
case SubPanelId.SCREEN_RECORDING:
|
||||
return <ScreenRecordingSidePanel />
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,6 +129,14 @@ export const Tools = () => {
|
||||
onPress={() => openTranscript()}
|
||||
/>
|
||||
)}
|
||||
{hasScreenRecordingAccess && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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('face-landmarks')
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
return featureEnabled || !isAnalyticsEnabled
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
|
||||
export const useHasTranscriptAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isTranscriptEnabled = useIsTranscriptEnabled()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isTranscriptEnabled
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsTranscriptEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export enum PanelId {
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
|
||||
export const useSidePanel = () => {
|
||||
@@ -24,6 +25,7 @@ export const useSidePanel = () => {
|
||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
@@ -57,6 +59,11 @@ export const useSidePanel = () => {
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
activeSubPanelId,
|
||||
@@ -66,6 +73,7 @@ export const useSidePanel = () => {
|
||||
toggleTools,
|
||||
toggleAdmin,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
isSubPanelOpen,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
@@ -74,5 +82,6 @@ export const useSidePanel = () => {
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isTranscriptOpen,
|
||||
isScreenRecordingOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { TranscriptStateToast } from '../components/TranscriptStateToast'
|
||||
import { RecordingStateToast } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
@@ -231,7 +231,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
<TranscriptStateToast />
|
||||
<RecordingStateToast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"open": "",
|
||||
"accept": ""
|
||||
},
|
||||
"recordingStarted": "",
|
||||
"recordingStopped": ""
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"screenRecording": "",
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
@@ -177,6 +178,7 @@
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"screenRecording": "",
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
@@ -192,6 +194,10 @@
|
||||
"transcript": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -207,12 +213,33 @@
|
||||
"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": {
|
||||
@@ -292,11 +319,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"recordingBadge": {
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"any": {
|
||||
"started": ""
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"open": "Open",
|
||||
"accept": "Accept"
|
||||
},
|
||||
"recordingStarted": "{{name}} started the meeting transcription.",
|
||||
"recordingStopped": "{{name}} stopped the meeting transcription."
|
||||
"transcript": {
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Recording",
|
||||
"admin": "Admin settings",
|
||||
"tools": "More tools"
|
||||
},
|
||||
@@ -175,7 +176,8 @@
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"chat": "messages",
|
||||
"transcript": "Transcription",
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "recording",
|
||||
"admin": "admin settings",
|
||||
"tools": "more tools"
|
||||
},
|
||||
@@ -190,7 +192,11 @@
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Transcribe your meeting for later"
|
||||
"body": "Keep a written record of your meeting."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Recording",
|
||||
"body": "Record your meeting to watch it again whenever you like."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -206,12 +212,33 @@
|
||||
"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.",
|
||||
"button": "Sign up"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Record this call",
|
||||
"body": "Record this call to watch it later and receive the video recording by email.",
|
||||
"button": "Start recording",
|
||||
"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.",
|
||||
"button": "Stop recording"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
|
||||
"access": {
|
||||
@@ -291,11 +318,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"recordingBadge": {
|
||||
"transcript": {
|
||||
"started": "Transcribing",
|
||||
"starting": "Transcription starting",
|
||||
"stopping": "Transcription stopping"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Recording in progress",
|
||||
"starting": "Starting recording",
|
||||
"stopping": "Stopping recording"
|
||||
},
|
||||
"any": {
|
||||
"started": "Recording in progress"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"open": "Afficher",
|
||||
"accept": "Accepter"
|
||||
},
|
||||
"recordingStarted": "{{name}} a démarré la transcription de la réunion.",
|
||||
"recordingStopped": "{{name}} a arrêté la transcription de la réunion."
|
||||
"transcript": {
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel",
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Enregistrement",
|
||||
"admin": "Commandes de l'organisateur",
|
||||
"tools": "Plus d'outils"
|
||||
},
|
||||
@@ -176,6 +177,7 @@
|
||||
"effects": "les effets",
|
||||
"chat": "les messages",
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "enregistrement",
|
||||
"admin": "commandes de l'organisateur",
|
||||
"tools": "plus d'outils"
|
||||
},
|
||||
@@ -190,7 +192,11 @@
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Transcrivez votre réunion pour plus tard"
|
||||
"body": "Conservez une trace écrite de votre réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Enregistrement",
|
||||
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -206,12 +212,33 @@
|
||||
"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.",
|
||||
"button": "Inscrivez-vous"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Enregistrer cet appel",
|
||||
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
|
||||
"button": "Démarrer 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é.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
|
||||
"access": {
|
||||
@@ -291,11 +318,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"recordingBadge": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
"starting": "Démarrage de la transcription",
|
||||
"stopping": "Arrêt de la transcription"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Enregistrement en cours",
|
||||
"starting": "Démarrage de l'enregistrement",
|
||||
"stopping": "Arrêt de l'enregistrement"
|
||||
},
|
||||
"any": {
|
||||
"started": "Enregistrement en cours"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"open": "Openen",
|
||||
"accept": "Accepteren"
|
||||
},
|
||||
"recordingStarted": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"recordingStopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
|
||||
"transcript": {
|
||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"effects": "Effecten",
|
||||
"chat": "Berichten in de chat",
|
||||
"transcript": "Transcriptie",
|
||||
"screenRecording": "Schermopname",
|
||||
"admin": "Beheerdersbediening",
|
||||
"tools": "Meer tools"
|
||||
},
|
||||
@@ -175,7 +176,8 @@
|
||||
"participants": "deelnemers",
|
||||
"effects": "effecten",
|
||||
"chat": "berichten",
|
||||
"transcript": "transcriptie",
|
||||
"screenRecording": "transcriptie",
|
||||
"transcript": "schermopname",
|
||||
"admin": "beheerdersbediening",
|
||||
"tools": "meer tools"
|
||||
},
|
||||
@@ -190,7 +192,11 @@
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcriptie",
|
||||
"body": "Transcribeer je vergadering voor later"
|
||||
"body": "Bewaar een schriftelijk verslag van je vergadering."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Opname",
|
||||
"body": "Neem je vergadering op om die later opnieuw te bekijken."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -206,12 +212,33 @@
|
||||
"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.",
|
||||
"button": "Aanmelden"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"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",
|
||||
"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.",
|
||||
"button": "Opname stoppen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
|
||||
"access": {
|
||||
@@ -291,11 +318,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"recordingBadge": {
|
||||
"transcript": {
|
||||
"started": "Transcriptie bezig",
|
||||
"starting": "Transcriptie begint",
|
||||
"stopping": "Transcriptie stopt"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Opname bezig",
|
||||
"starting": "Opname starten",
|
||||
"stopping": "Opname stoppen"
|
||||
},
|
||||
"any": {
|
||||
"started": "Opname bezig"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export enum RecordingStatus {
|
||||
TRANSCRIPT_STARTING,
|
||||
TRANSCRIPT_STARTED,
|
||||
TRANSCRIPT_STOPPING,
|
||||
STOPPED,
|
||||
SCREEN_RECORDING_STARTING,
|
||||
SCREEN_RECORDING_STARTED,
|
||||
SCREEN_RECORDING_STOPPING,
|
||||
ANY_STARTED,
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: RecordingStatus
|
||||
}
|
||||
|
||||
export const recordingStore = proxy<State>({
|
||||
status: RecordingStatus.STOPPED,
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export enum TranscriptionStatus {
|
||||
STARTING,
|
||||
STARTED,
|
||||
STOPPING,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: TranscriptionStatus
|
||||
}
|
||||
|
||||
export const transcriptionStore = proxy<State>({
|
||||
status: TranscriptionStatus.STOPPED,
|
||||
})
|
||||
@@ -9,3 +9,6 @@ export const CRISP_HELP_ARTICLE_MORE_TOOLS =
|
||||
|
||||
export const CRISP_HELP_ARTICLE_TRANSCRIPT =
|
||||
'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x' as const
|
||||
|
||||
export const CRISP_HELP_ARTICLE_RECORDING =
|
||||
'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0' as const
|
||||
|
||||
@@ -16,6 +16,10 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
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: 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
|
||||
@@ -61,6 +65,7 @@ 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
|
||||
|
||||
|
||||
@@ -195,3 +200,17 @@ celery:
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
@@ -32,6 +32,10 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
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: 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
|
||||
@@ -83,6 +87,7 @@ 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:
|
||||
@@ -223,3 +228,17 @@ celery:
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
+44
-28
@@ -4,34 +4,50 @@
|
||||
|
||||
### 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 | |
|
||||
| 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` | | `{}` |
|
||||
|
||||
### backend
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{{- if .Values.ingressMedia.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressMedia.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressMedia.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressMedia.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressMedia.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- host: {{ .Values.ingressMedia.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: ImplementationSpecific
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ $.Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: ImplementationSpecific
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- $component := "media" -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
annotations:
|
||||
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: {{ $.Values.serviceMedia.host }}
|
||||
@@ -69,6 +69,48 @@ 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
|
||||
## @param ingressMedia.path Path to use for the Ingress
|
||||
ingressMedia:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /media/(.*)
|
||||
## @param ingressMedia.hosts Additional host to configure for the Ingress
|
||||
hosts: [ ]
|
||||
# - chart-example.local
|
||||
## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressMedia.tls.secretName Secret name for TLS config
|
||||
## @skip ingressMedia.tls.additional
|
||||
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: null
|
||||
additional: []
|
||||
|
||||
## @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"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
|
||||
## @param serviceMedia.host
|
||||
## @param serviceMedia.port
|
||||
## @param serviceMedia.annotations
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
|
||||
|
||||
## @section backend
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<mjml>
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
<mj-image
|
||||
align="center"
|
||||
src="{{logo_img}}"
|
||||
width="320px"
|
||||
alt="{%trans 'Logo email' %}"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
|
||||
<mj-column>
|
||||
<mj-text align="center">
|
||||
<h1>{% trans "Your recording is ready!"%}</h1>
|
||||
</mj-text>
|
||||
<!-- Main Message -->
|
||||
<mj-text>
|
||||
{% blocktrans %}
|
||||
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
<p>{% trans "To keep this recording permanently:" %}</p>
|
||||
<ol>
|
||||
<li>{% trans "Click the \"Open\" button below" %}</li>
|
||||
<li>{% trans "Use the \"Download\" button in the interface" %}</li>
|
||||
<li>{% trans "Save the file to your preferred location" %}</li>
|
||||
</ol>
|
||||
</mj-text>
|
||||
<mj-button
|
||||
href="{{link}}"
|
||||
background-color="#000091"
|
||||
color="white"
|
||||
padding-bottom="30px"
|
||||
>
|
||||
{% trans "Open"%}
|
||||
</mj-button>
|
||||
<mj-text>
|
||||
{% blocktrans %}
|
||||
If you have any questions or need assistance, please contact our support team at {{support_email}}.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-divider
|
||||
border-width="1px"
|
||||
border-style="solid"
|
||||
border-color="#DDDDDD"
|
||||
width="30%"
|
||||
align="center"
|
||||
/>
|
||||
<!-- Signature -->
|
||||
<mj-text>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Thank you for using {{brandname}}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user