Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68ce56c1c8 | |||
| c289f79d8e | |||
| b79fa14919 | |||
| c7c0df5b6d | |||
| cb00347be6 | |||
| bcb004ab4b | |||
| 422f838899 | |||
| 462c6c50e5 | |||
| 63565b38c3 | |||
| 10d759bdbb | |||
| 51f1f0ebbf | |||
| 9e27d0f345 | |||
| 978d931bd7 | |||
| 0c811222d4 | |||
| 94171dcb82 | |||
| 56c1cd98fa | |||
| f2e6edb90d | |||
| bc76c44fe9 | |||
| e519f00342 | |||
| 2246bb7782 | |||
| e210f26f9c | |||
| 888dfe76c7 | |||
| ae17fbdaa8 | |||
| 2ef95aa835 | |||
| a83e5c4b1c | |||
| 9cc79ba159 | |||
| c63adf9c8c | |||
| 9366c8c4dd | |||
| 3636bfa703 | |||
| 34c14cc516 | |||
| 1a0051a90b | |||
| af21478143 | |||
| 986b75ba39 | |||
| df55fb2424 | |||
| ec114808b2 | |||
| 7f0e866eac | |||
| 8e0e286bc4 | |||
| ee9148fe9f | |||
| c863bc7456 | |||
| eab361326f | |||
| 86c82f584c | |||
| af6ac954e9 | |||
| 35cc6cd902 | |||
| 9cb7b0f154 | |||
| d105603a9b | |||
| d2da1e37b9 | |||
| 0206762e6d | |||
| c4fe341a74 |
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -72,6 +72,8 @@ services:
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
@@ -287,6 +287,7 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
@@ -301,6 +302,7 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
@@ -308,7 +310,6 @@ These are the environmental options available on meet backend.
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
|
||||
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
|
||||
@@ -48,7 +48,8 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||
LIVEKIT_VERIFY_SSL=False
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
|
||||
@@ -40,6 +40,7 @@ def get_frontend_configuration(request):
|
||||
"recording": {
|
||||
"is_enabled": settings.RECORDING_ENABLE,
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -11,9 +12,11 @@ from core import models, utils
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -117,7 +120,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
|
||||
if role is not None:
|
||||
if is_admin:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -156,7 +159,17 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status", "mode", "key"]
|
||||
fields = [
|
||||
"id",
|
||||
"room",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -531,40 +531,12 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list rooms access"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
ResourceAccessListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -575,6 +547,25 @@ class ResourceAccessViewSet(
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Restrict access to resources the user either has explicit
|
||||
# permissions for or administrative privileges over.
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
@@ -17,93 +18,46 @@ from core.services.marketing import (
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
return {
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
Returns:
|
||||
- None
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
"""
|
||||
email = claims["email"]
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
@@ -142,32 +96,3 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_alter_room_access_level'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
@@ -3,8 +3,9 @@ Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -12,6 +13,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -601,6 +603,34 @@ class Recording(BaseModel):
|
||||
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
|
||||
|
||||
@property
|
||||
def expired_at(self) -> Optional[datetime]:
|
||||
"""
|
||||
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
|
||||
Returns None if no expiration is configured.
|
||||
|
||||
Note: This is a naive and imperfect implementation since recordings are actually
|
||||
saved to the bucket after created_at timestamp is set. The actual expiration
|
||||
will be determined by the bucket lifecycle policy which operates on the object's
|
||||
timestamp in the storage system, not this value.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_EXPIRATION_DAYS:
|
||||
return None
|
||||
|
||||
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
Determine if the recording has expired by comparing expired_at with current UTC time.
|
||||
Returns False if no expiration is configured or if expiration date is in the future.
|
||||
"""
|
||||
if not self.expired_at:
|
||||
return False
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -44,49 +44,69 @@ class NotificationService:
|
||||
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,
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
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"),
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
}
|
||||
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
has_failures = False
|
||||
|
||||
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,
|
||||
# We process emails individually rather than in batch because:
|
||||
# 1. Each email requires personalization (timezone, language)
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
return False
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
return True
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
|
||||
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
|
||||
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
verify_ssl: Optional[bool]
|
||||
bucket_args: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
verify_ssl=settings.RECORDING_VERIFY_SSL,
|
||||
bucket_args={
|
||||
"endpoint": settings.AWS_S3_ENDPOINT_URL,
|
||||
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
from livekit.api.egress_service import EgressService
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
@@ -29,21 +28,26 @@ class BaseEgressService:
|
||||
async def _handle_request(self, request, method_name: str):
|
||||
"""Handle making a request to the LiveKit API and returns the response."""
|
||||
|
||||
# Use HTTP connector for local development with Tilt,
|
||||
# where cluster communications are unsecure
|
||||
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
|
||||
lkapi = utils.create_livekit_client(self._config.server_configurations)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
client = EgressService(session, **self._config.server_configurations)
|
||||
method = getattr(client, method_name)
|
||||
try:
|
||||
response = await method(request)
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
# ruff: noqa: SLF001
|
||||
# pylint: disable=protected-access
|
||||
method = getattr(lkapi._egress, method_name)
|
||||
|
||||
try:
|
||||
response = await method(request)
|
||||
return response
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise WorkerConnectionError(
|
||||
f"Unexpected error during LiveKit client connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
|
||||
@@ -14,7 +14,6 @@ from django.core.cache import cache
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
@@ -347,7 +346,7 @@ class LobbyService:
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
@@ -4,6 +4,7 @@ Test event notification.
|
||||
|
||||
# pylint: disable=E1128,W0621,W0613,W0212
|
||||
|
||||
import datetime
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
@@ -74,6 +75,7 @@ def test_notify_external_services_unknown_mode(caplog):
|
||||
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
"""Test successful email notification to recording owners."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
@@ -84,15 +86,28 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
|
||||
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]
|
||||
# Fix recording test to avoid flaky tests
|
||||
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
|
||||
|
||||
french_user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
dutch_user = factories.UserFactory(
|
||||
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
|
||||
)
|
||||
english_user = factories.UserFactory(
|
||||
email="john@test.com", language="en-us", timezone="America/Phoenix"
|
||||
)
|
||||
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=french_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
||||
)
|
||||
|
||||
# Create non-owner users to verify they don't receive emails
|
||||
factories.UserRecordingAccessFactory(
|
||||
@@ -108,30 +123,69 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_send_mail.assert_called_once()
|
||||
assert mock_send_mail.call_count == 3
|
||||
|
||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
||||
call_args_list = mock_send_mail.call_args_list
|
||||
|
||||
assert subject == "Your recording is ready"
|
||||
|
||||
# Verify email contains expected content
|
||||
required_content = [
|
||||
base_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
|
||||
# First call verification
|
||||
subject1, body1, sender1, recipients1 = call_args_list[0][0]
|
||||
assert subject1 == "Votre enregistrement est prêt"
|
||||
|
||||
assert sender == "notifications@acme.com"
|
||||
# Verify email contains expected content
|
||||
personalized_content1 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Votre enregistrement est prêt !", # Intro
|
||||
]
|
||||
for content in personalized_content1:
|
||||
assert content in body1
|
||||
|
||||
# Verify all owners received the email (order-independent comparison)
|
||||
assert sorted(recipients) == sorted(owner_emails)
|
||||
assert recipients1 == ["franc@test.com"]
|
||||
assert sender1 == "notifications@acme.com"
|
||||
|
||||
# Second call verification
|
||||
subject2, body2, sender2, recipients2 = call_args_list[1][0]
|
||||
assert subject2 == "Je opname is klaar"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content2 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Je opname is klaar!", # Intro
|
||||
]
|
||||
for content in personalized_content2:
|
||||
assert content in body2
|
||||
|
||||
assert recipients2 == ["berry@test.com"]
|
||||
assert sender2 == "notifications@acme.com"
|
||||
|
||||
# Third call verification
|
||||
subject3, body3, sender3, recipients3 = call_args_list[2][0]
|
||||
assert subject3 == "Your recording is ready"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content3 = [
|
||||
*base_content,
|
||||
"Conference Room A", # Room name
|
||||
"2023-05-15", # Formatted date
|
||||
"07:30", # Formatted time
|
||||
"Your recording is ready!", # Intro
|
||||
]
|
||||
for content in personalized_content3:
|
||||
assert content in body3
|
||||
|
||||
assert recipients3 == ["john@test.com"]
|
||||
assert sender3 == "notifications@acme.com"
|
||||
|
||||
|
||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||
@@ -153,6 +207,9 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -163,5 +220,5 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
mock_send_mail.assert_called_once()
|
||||
assert mock_send_mail.call_count == 2
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
@@ -47,11 +47,12 @@ def test_api_recordings_list_authenticated_no_recording():
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
)
|
||||
def test_api_recordings_list_authenticated_direct(role):
|
||||
def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
settings.RECORDING_EXPIRATIONS_DAYS = None
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -89,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
},
|
||||
"status": "initiated",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFac
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_media_auth_unauthenticated():
|
||||
def test_api_recordings_media_auth_unauthenticated():
|
||||
"""
|
||||
Test that unauthenticated requests to download media are rejected
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ def test_api_documents_media_auth_unauthenticated():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_documents_media_auth_wrong_path():
|
||||
def test_api_recordings_media_auth_wrong_path():
|
||||
"""
|
||||
Test that media URLs with incorrect path structures are rejected
|
||||
"""
|
||||
@@ -51,7 +51,7 @@ def test_api_documents_media_auth_wrong_path():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_unknown_recording():
|
||||
def test_api_recordings_media_auth_unknown_recording():
|
||||
"""
|
||||
Test that requests for non-existent recordings are properly handled
|
||||
"""
|
||||
@@ -69,7 +69,7 @@ def test_api_documents_media_auth_unknown_recording():
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_documents_media_auth_no_abilities():
|
||||
def test_api_recordings_media_auth_no_abilities():
|
||||
"""
|
||||
Test that users without any access permissions cannot download recordings
|
||||
"""
|
||||
@@ -88,7 +88,7 @@ def test_api_documents_media_auth_no_abilities():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_wrong_abilities():
|
||||
def test_api_recordings_media_auth_wrong_abilities():
|
||||
"""
|
||||
Test that users with insufficient role permissions cannot download recordings
|
||||
"""
|
||||
@@ -111,7 +111,7 @@ def test_api_documents_media_auth_wrong_abilities():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||
def test_api_documents_media_auth_unsaved(wrong_status):
|
||||
def test_api_recordings_media_auth_unsaved(wrong_status):
|
||||
"""
|
||||
Test that recordings that aren't in 'saved' status cannot be downloaded
|
||||
"""
|
||||
@@ -132,7 +132,7 @@ def test_api_documents_media_auth_unsaved(wrong_status):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_mismatched_extension():
|
||||
def test_api_recordings_media_auth_mismatched_extension():
|
||||
"""
|
||||
Test that requests with mismatched file extensions are rejected
|
||||
"""
|
||||
@@ -160,7 +160,7 @@ def test_api_documents_media_auth_mismatched_extension():
|
||||
@pytest.mark.parametrize(
|
||||
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
|
||||
)
|
||||
def test_api_documents_media_auth_wrong_extension(wrong_extension):
|
||||
def test_api_recordings_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.
|
||||
@@ -185,7 +185,7 @@ def test_api_documents_media_auth_wrong_extension(wrong_extension):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_documents_media_auth_success_owner(mode):
|
||||
def test_api_recordings_media_auth_success_owner(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
@@ -235,7 +235,7 @@ def test_api_documents_media_auth_success_owner(mode):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_documents_media_auth_success_administrator(mode):
|
||||
def test_api_recordings_media_auth_success_administrator(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
Test recordings API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
@@ -61,8 +64,9 @@ def test_api_recording_retrieve_members():
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_administrators():
|
||||
def test_api_recording_retrieve_administrators(settings):
|
||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
@@ -91,11 +95,14 @@ def test_api_recording_retrieve_administrators():
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_owners():
|
||||
def test_api_recording_retrieve_owners(settings):
|
||||
"""A user who is an owner of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
@@ -123,6 +130,87 @@ def test_api_recording_retrieve_owners():
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 1
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-16T12:00:00Z",
|
||||
"is_expired": False, # Ensure the recording is still valid and hasn't expired
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_expired(settings):
|
||||
"""Test that the API returns the correct expiration date and flag for an expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 2
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
with freeze_time("2023-01-15 12:00:00"):
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-17T12:00:00Z",
|
||||
"is_expired": True, # Ensure the recording has expired
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ def test_settings():
|
||||
mocked_settings = {
|
||||
"RECORDING_OUTPUT_FOLDER": "/test/output",
|
||||
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
|
||||
"RECORDING_VERIFY_SSL": True,
|
||||
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
|
||||
"AWS_S3_ACCESS_KEY_ID": "test_key",
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
|
||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
||||
assert default_config.output_folder == "/test/output"
|
||||
assert default_config.server_configurations == {"server": "test.example.com"}
|
||||
assert default_config.verify_ssl is True
|
||||
assert default_config.bucket_args == {
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
RECORDING_VERIFY_SSL=True,
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
|
||||
@@ -6,10 +6,9 @@ Test worker service classes.
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from core.recording.worker.exceptions import WorkerResponseError
|
||||
from core.recording.worker.factories import WorkerServiceConfig
|
||||
from core.recording.worker.services import (
|
||||
AudioCompositeEgressService,
|
||||
@@ -25,11 +24,10 @@ def config():
|
||||
return WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"url": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=True,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
assert result.endswith(f"{filename}.{extension}")
|
||||
|
||||
|
||||
def test_base_egress_handle_request_success(
|
||||
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
|
||||
):
|
||||
"""Test successful request handling"""
|
||||
# Setup mock response
|
||||
mock_response = Mock()
|
||||
mock_method = AsyncMock(return_value=mock_response)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
response = service._handle_request(test_request, "test_method")
|
||||
|
||||
mock_client_session.assert_called_once_with(
|
||||
connector=mock_tcp_connector.return_value
|
||||
)
|
||||
|
||||
# Verify EgressService initialization
|
||||
mock_egress_service.assert_called_once_with(
|
||||
mock_client_session.return_value.__aenter__.return_value,
|
||||
**service._config.server_configurations,
|
||||
)
|
||||
|
||||
# Verify method call and response
|
||||
mock_method.assert_called_once_with(test_request)
|
||||
assert response == mock_response
|
||||
|
||||
|
||||
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
|
||||
"""Test handling of connection errors"""
|
||||
# Setup mock error
|
||||
mock_method = AsyncMock(
|
||||
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
|
||||
)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
# Verify error handling
|
||||
with pytest.raises(WorkerConnectionError) as exc:
|
||||
service._handle_request(test_request, "test_method")
|
||||
|
||||
assert "LiveKit client connection error" in str(exc.value)
|
||||
assert "Connection failed" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,expected_result",
|
||||
[
|
||||
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
|
||||
assert "Subclass must implement this method" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_ssl", [True, False])
|
||||
def test_base_egress_ssl_verification_config(verify_ssl):
|
||||
"""Test SSL verification configuration"""
|
||||
config = WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=verify_ssl,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
service = BaseEgressService(config)
|
||||
|
||||
# Mock ClientSession to capture connector configuration
|
||||
with patch("aiohttp.ClientSession") as mock_session:
|
||||
mock_session.return_value.__aenter__ = AsyncMock()
|
||||
mock_session.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
# Trigger request to verify connector configuration
|
||||
service._handle_request(Mock(), "test_method")
|
||||
|
||||
# Verify SSL configuration
|
||||
connector = mock_session.call_args[1]["connector"]
|
||||
assert isinstance(connector, aiohttp.TCPConnector)
|
||||
assert connector._ssl == verify_ssl
|
||||
|
||||
|
||||
def test_video_composite_egress_hrid(video_service):
|
||||
"""Test HRID is correct"""
|
||||
assert video_service.hrid == "video-recording-composite-livekit-egress"
|
||||
|
||||
@@ -336,23 +336,22 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory()
|
||||
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
other_user_access = UserResourceAccessFactory(
|
||||
resource=room, user=other_user, role="member"
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -360,33 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
assert response.status_code == 200
|
||||
content_dict = response.json()
|
||||
|
||||
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
},
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
@@ -415,11 +388,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
|
||||
def test_api_rooms_retrieve_administrators(
|
||||
mock_token, django_assert_num_queries, settings
|
||||
):
|
||||
"""
|
||||
A user who is an administrator or owner of a room should be allowed
|
||||
to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
room = RoomFactory()
|
||||
@@ -448,6 +424,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -459,6 +437,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
|
||||
@@ -776,9 +776,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
@@ -794,15 +795,11 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -813,8 +810,8 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
@@ -830,14 +827,11 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -855,15 +849,15 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
@@ -874,7 +868,7 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
@@ -882,9 +876,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
|
||||
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
def test_api_users_retrieve_me_authenticated(settings):
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from core.utils import create_livekit_client
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_enabled(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification enabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.aiohttp.ClientSession")
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_disabled(
|
||||
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification disabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
mock_session_instance = mock.MagicMock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
settings.LIVEKIT_VERIFY_SSL = False
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_custom_configuration(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with custom configuration."""
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
custom_configuration = {
|
||||
"api_key": "mock_key",
|
||||
"api_secret": "mock_secret",
|
||||
"url": "http://mock-url.com",
|
||||
}
|
||||
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||
@@ -3,10 +3,10 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -13,8 +13,9 @@ from uuid import uuid4
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
@@ -142,3 +143,18 @@ def generate_s3_authorization_headers(key):
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def create_livekit_client(custom_configuration=None):
|
||||
"""Create and return a configured LiveKit API client."""
|
||||
|
||||
custom_session = None
|
||||
|
||||
if not settings.LIVEKIT_VERIFY_SSL:
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
custom_session = aiohttp.ClientSession(connector=connector)
|
||||
|
||||
# Use default configuration if none provided
|
||||
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
return LiveKitAPI(session=custom_session, **configuration)
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
|
||||
"POT-Creation-Date: 2025-04-23 14: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"
|
||||
@@ -23,390 +23,367 @@ msgstr ""
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/api/serializers.py:60
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
msgstr "User info contained no recognizable user identification"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
msgstr "User account is disabled"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
msgstr "Multiple user accounts share a common email."
|
||||
|
||||
#: core/models.py:27
|
||||
#: core/models.py:30
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:28
|
||||
#: core/models.py:31
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:29
|
||||
#: core/models.py:32
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Saved"
|
||||
msgstr ""
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Aborted"
|
||||
msgstr ""
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Failed to Start"
|
||||
msgstr ""
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Failed to Stop"
|
||||
msgstr ""
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr ""
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:79
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr ""
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:80
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr ""
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr ""
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr ""
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr ""
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:100
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:134
|
||||
#: core/models.py:137
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:140
|
||||
#: core/models.py:143
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:142
|
||||
#: core/models.py:145
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:155
|
||||
#: core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
#: core/models.py:178
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
#: core/models.py:183
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:188
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:191
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:201
|
||||
#: core/models.py:204
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:205
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:261
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:262
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:316
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:317
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:379
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:380
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:386 core/models.py:506
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:517
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:519
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:527
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:528
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr ""
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:534
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr ""
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:535
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr ""
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:592
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr ""
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:593
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr ""
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr ""
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:605
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr ""
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:611
|
||||
#: core/models.py:665
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr ""
|
||||
msgstr "Authentication is enabled but token is not configured."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr ""
|
||||
msgstr "Authorization header is required"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
msgstr "Invalid authorization header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
msgstr "Invalid token"
|
||||
|
||||
#: core/recording/event/notification.py:81
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr ""
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "invites you to join an ongoing video call"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Meet</strong>"
|
||||
msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "JOIN THE CALL"
|
||||
|
||||
#: 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
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Meet, your new companion "
|
||||
"to collaborate on documents efficiently, intuitively, and securely."
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: 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 ""
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips for a better experience:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create documents."
|
||||
msgstr ""
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use Chrome or Firefox for better call quality"
|
||||
|
||||
#: 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 ""
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test your microphone and camera before joining"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:249
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Visit Meet"
|
||||
msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Make sure you have a stable internet connection"
|
||||
|
||||
#: 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/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Thank you for using %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr ""
|
||||
msgstr "Your recording is ready!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
@@ -415,31 +392,39 @@ msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr ""
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \\"
|
||||
msgstr ""
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \\"
|
||||
msgstr ""
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use the \"Download\" button in the interface "
|
||||
|
||||
#: 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 ""
|
||||
msgstr "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
@@ -448,21 +433,17 @@ 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 ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
Binary file not shown.
@@ -8,9 +8,9 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -19,394 +19,379 @@ msgstr ""
|
||||
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/serializers.py:60
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
"Les informations utilisateur ne contiennent aucune identification "
|
||||
"utilisateur reconnaissable"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
msgstr "Le compte utilisateur est désactivé"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
|
||||
|
||||
#: core/models.py:27
|
||||
#: core/models.py:30
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:28
|
||||
#: core/models.py:31
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:29
|
||||
#: core/models.py:32
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Saved"
|
||||
msgstr ""
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Aborted"
|
||||
msgstr ""
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Failed to Start"
|
||||
msgstr ""
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Failed to Stop"
|
||||
msgstr ""
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr ""
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:79
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr ""
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:80
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr ""
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr ""
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr ""
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr ""
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:100
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:134
|
||||
#: core/models.py:137
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
|
||||
"des chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:140
|
||||
#: core/models.py:143
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "identifiant"
|
||||
|
||||
#: core/models.py:142
|
||||
#: core/models.py:145
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:155
|
||||
#: core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
#: core/models.py:178
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
#: core/models.py:183
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:188
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:191
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:201
|
||||
#: core/models.py:204
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:205
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:261
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:262
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:316
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:317
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:379
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:380
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:386 core/models.py:506
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:517
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:519
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
|
||||
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
|
||||
"facile."
|
||||
|
||||
#: core/models.py:527
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:528
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr ""
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:534
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr ""
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:535
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr ""
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:592
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr ""
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:593
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr ""
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr ""
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:605
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr ""
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:611
|
||||
#: core/models.py:665
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr ""
|
||||
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr ""
|
||||
msgstr "L'en-tête d'autorisation est requis"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
msgstr "En-tête d'autorisation invalide."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
msgstr "Jeton invalide"
|
||||
|
||||
#: core/recording/event/notification.py:81
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr ""
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "vous invite à rejoindre un appel vidéo en cours"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Meet</strong>"
|
||||
msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "REJOINDRE L'APPEL"
|
||||
|
||||
#: 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
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Meet, your new companion "
|
||||
"to collaborate on documents efficiently, intuitively, and securely."
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
|
||||
"votre navigateur pour rejoindre l'appel."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: 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 ""
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Conseils pour une meilleure expérience :"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create documents."
|
||||
msgstr ""
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
|
||||
|
||||
#: 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 ""
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:249
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Visit Meet"
|
||||
msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
||||
|
||||
#: 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/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Merci d'utiliser %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr ""
|
||||
msgstr "Votre enregistrement est prêt !"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
@@ -415,31 +400,39 @@ msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
|
||||
"%(recording_time)s est maintenant prêt à être téléchargé. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr ""
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \\"
|
||||
msgstr ""
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \\"
|
||||
msgstr ""
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||
|
||||
#: 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 ""
|
||||
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
@@ -448,21 +441,17 @@ 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 ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,449 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14: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"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr "Rechten"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "Gebruikersaccount is uitgeschakeld"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
|
||||
|
||||
#: core/models.py:30
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:31
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:32
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:137
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:178
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:185
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:188
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:191
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:204
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:665
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "Autorisatie-header is vereist"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "Ongeldige autorisatie-header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Ongeldig token"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "NEEM DEEL AAN DE OPROEP"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
|
||||
"browser om deel te nemen aan de oproep."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips voor een betere ervaring:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test je microfoon en camera voordat je deelneemt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Bedankt voor het gebruik van %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Je opname is klaar!"
|
||||
|
||||
#: 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 ""
|
||||
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
|
||||
"is nu klaar om te downloaden. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " De opname verloopt over %(days)s dagen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Gebruik de \"Download\"-knop in de interface "
|
||||
|
||||
#: 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 "Sla het bestand op naar je gewenste locatie"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Openen"
|
||||
|
||||
#: 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 ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
@@ -23,7 +23,6 @@ from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join("/", "data")
|
||||
|
||||
|
||||
def get_release():
|
||||
@@ -70,6 +69,8 @@ class Base(Configuration):
|
||||
|
||||
API_VERSION = "v1.0"
|
||||
|
||||
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
@@ -160,6 +161,7 @@ class Base(Configuration):
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("nl-nl", _("Dutch")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -261,6 +263,9 @@ class Base(Configuration):
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
@@ -310,6 +315,9 @@ class Base(Configuration):
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
|
||||
),
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -354,8 +362,8 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
@@ -389,6 +397,9 @@ class Base(Configuration):
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -435,6 +446,11 @@ class Base(Configuration):
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
@@ -444,6 +460,9 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
|
||||
)
|
||||
@@ -458,9 +477,6 @@ class Base(Configuration):
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
||||
{
|
||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
||||
@@ -483,6 +499,11 @@ class Base(Configuration):
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
||||
# Set to None for no expiration
|
||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.18"
|
||||
version = "0.1.21"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-lasuite==0.0.7",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
@@ -55,7 +56,7 @@ dependencies = [
|
||||
"sentry-sdk==2.24.1",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.2",
|
||||
"livekit-api==1.0.2",
|
||||
"aiohttp==3.11.14",
|
||||
]
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
@@ -49,7 +49,7 @@
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.4",
|
||||
"vite": "6.2.6",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -9571,9 +9571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
|
||||
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
|
||||
"version": "6.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -54,7 +54,7 @@
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.4",
|
||||
"vite": "6.2.6",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,17 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
|
||||
@@ -11,11 +11,15 @@ export interface ApiConfig {
|
||||
support?: {
|
||||
id: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,9 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
@@ -16,10 +12,8 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -35,7 +39,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
|
||||
<A href={data?.feedback?.url} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const fetchUser = (
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(300)
|
||||
attemptSilentLogin(30)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
|
||||
|
||||
export const updateUserPreferences = async ({
|
||||
user,
|
||||
}: {
|
||||
user: ApiUserPreferences
|
||||
}): Promise<ApiUser> => {
|
||||
return await fetchApi(`/users/${user.id}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { updateUserPreferences } from './updateUserPreferences'
|
||||
import { convertToBackendLanguage } from '@/utils/languages'
|
||||
import { useUser } from './useUser'
|
||||
|
||||
/**
|
||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||
* Automatically updates backend when browser settings change for logged-in users.
|
||||
*/
|
||||
export const useSyncUserPreferencesWithBackend = () => {
|
||||
const { i18n } = useTranslation()
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
const { mutateAsync } = useMutation({
|
||||
mutationFn: updateUserPreferences,
|
||||
onSuccess: (updatedUser) => {
|
||||
queryClient.setQueryData([keys.user], updatedUser)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !isLoggedIn) return
|
||||
|
||||
const syncBrowserPreferencesToBackend = async () => {
|
||||
const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const currentLanguage = convertToBackendLanguage(i18n.language)
|
||||
if (
|
||||
currentLanguage !== user.language ||
|
||||
currentTimezone !== user.timezone
|
||||
) {
|
||||
await mutateAsync({
|
||||
user: {
|
||||
id: user.id,
|
||||
timezone: currentTimezone,
|
||||
language: currentLanguage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
syncBrowserPreferencesToBackend()
|
||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||
}
|
||||
@@ -24,24 +24,25 @@ export const useUser = (
|
||||
fetchUserOptions?: Parameters<typeof fetchUser>[0]
|
||||
} = {}
|
||||
) => {
|
||||
const { data, isLoading } = useConfig()
|
||||
const { data, isLoading: isConfigLoading } = useConfig()
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data?.is_silent_login_enabled !== true) {
|
||||
if (isConfigLoading) return
|
||||
if (data?.is_silent_login_enabled !== true) {
|
||||
return {
|
||||
...opts,
|
||||
attemptSilent: false,
|
||||
}
|
||||
}
|
||||
return opts.fetchUserOptions
|
||||
}, [data, opts])
|
||||
}, [data, opts, isConfigLoading])
|
||||
|
||||
const query = useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: [keys.user],
|
||||
queryFn: () => fetchUser(options),
|
||||
staleTime: Infinity,
|
||||
enabled: !isLoading,
|
||||
enabled: !isConfigLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
|
||||
@@ -9,6 +9,8 @@ export type RecordingApi = {
|
||||
key: string
|
||||
mode: RecordingMode
|
||||
status: RecordingStatus
|
||||
is_expired: boolean
|
||||
expired_at: string
|
||||
}
|
||||
|
||||
export const fetchRecording = ({ recordingId }: { recordingId?: string }) => {
|
||||
|
||||
@@ -27,5 +27,6 @@ export function useStartRecording(
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,5 +19,6 @@ export function useStopRecording(
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -6,19 +6,19 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '@/features/recording'
|
||||
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
@@ -29,12 +29,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -50,6 +58,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}, [recordingSnap])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,8 +103,11 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -145,7 +157,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -193,7 +205,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -206,6 +218,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useStopRecording,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import {
|
||||
@@ -32,6 +32,8 @@ export const TranscriptSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
@@ -42,8 +44,15 @@ export const TranscriptSidePanel = () => {
|
||||
)
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -58,6 +67,7 @@ export const TranscriptSidePanel = () => {
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
@@ -98,8 +108,11 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -177,7 +190,7 @@ export const TranscriptSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -225,7 +238,7 @@ export const TranscriptSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -240,6 +253,20 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { fetchRecording } from '../api/fetchRecording'
|
||||
import { RecordingStatus } from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const RecordingDownload = () => {
|
||||
const { t } = useTranslation('recording')
|
||||
const { data: configData } = useConfig()
|
||||
const { recordingId } = useParams()
|
||||
const { isLoggedIn, isLoading: isAuthLoading } = useUser()
|
||||
|
||||
@@ -50,6 +52,17 @@ export const RecordingDownload = () => {
|
||||
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
|
||||
}
|
||||
|
||||
if (data.is_expired) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('expired.title')}
|
||||
body={t('expired.body', {
|
||||
date: formatDate(data?.expired_at, 'YYYY-MM-DD HH:mm'),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen layout="centered" footer={false}>
|
||||
@@ -74,6 +87,16 @@ export const RecordingDownload = () => {
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{configData?.recording?.expiration_days && (
|
||||
<>
|
||||
{' '}
|
||||
{t('success.expiration', {
|
||||
expiration_days: configData?.recording?.expiration_days,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Text>
|
||||
<LinkButton
|
||||
href={mediaUrl(data.key)}
|
||||
|
||||
@@ -353,7 +353,6 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -299,6 +300,7 @@ const AuthenticationMessage = ({
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
@@ -307,6 +309,8 @@ export const Rating = () => {
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
|
||||
const { identity, name } = useParticipantInfo({ participant })
|
||||
const displayedName = name != '' ? name : identity
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
@@ -29,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -97,6 +97,8 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
@@ -129,45 +131,50 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
minHeight: '24px',
|
||||
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||
color: isHandRaised ? 'black' : undefined,
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isHandRaised && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<LockLockedIcon
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
<ParticipantName>'s screen</ParticipantName>
|
||||
</>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
|
||||
@@ -110,9 +110,10 @@ const StyledSidePanel = ({
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
@@ -121,7 +122,7 @@ const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -160,7 +161,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
|
||||
+5
-2
@@ -2,14 +2,17 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
href={data?.feedback?.url}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
+10
-8
@@ -33,19 +33,20 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const label = isLocal(participant)
|
||||
? t('participants.muteYourself')
|
||||
: t('participants.muteParticipant', {
|
||||
name,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={
|
||||
isLocal(participant)
|
||||
? t('participants.muteYourself')
|
||||
: t('participants.muteParticipant', {
|
||||
name,
|
||||
})
|
||||
}
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted}
|
||||
onPress={() =>
|
||||
!isMuted && isLocal(participant)
|
||||
@@ -55,7 +56,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} />
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
@@ -64,6 +65,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
@@ -56,6 +57,12 @@ export const ReactionsToggle = () => {
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
const debouncedSendReaction = useRateLimiter({
|
||||
callback: sendReaction,
|
||||
maxCalls: 10,
|
||||
windowMs: 1000,
|
||||
})
|
||||
|
||||
// Custom animation implementation for the emoji toolbar
|
||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
||||
// animation isn't perfect
|
||||
@@ -127,7 +134,7 @@ export const ReactionsToggle = () => {
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
@@ -24,6 +23,7 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -38,6 +38,8 @@ export function MobileControlBar({
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -150,17 +152,19 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
{data?.feedback?.url && (
|
||||
<LinkButton
|
||||
href={data?.feedback?.url}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const useIsSdkContext = () => {
|
||||
const [location] = useLocation()
|
||||
return location.includes(SDK_BASE_ROUTE)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RateLimiterProps<T extends (...args: any[]) => any> = {
|
||||
callback: T
|
||||
maxCalls: number
|
||||
windowMs: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function useRateLimiter<T extends (...args: any[]) => any>({
|
||||
callback,
|
||||
maxCalls = 5,
|
||||
windowMs = 1000,
|
||||
}: RateLimiterProps<T>) {
|
||||
const callsCountRef = useRef(0)
|
||||
const resetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const rateLimitedFn = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (callsCountRef.current < maxCalls) {
|
||||
callsCountRef.current += 1
|
||||
if (callsCountRef.current === 1) {
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
callsCountRef.current = 0
|
||||
resetTimeoutRef.current = undefined
|
||||
}, windowMs)
|
||||
}
|
||||
return callback(...args)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[callback, maxCalls, windowMs]
|
||||
)
|
||||
return rateLimitedFn
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"expired": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"authentication": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
@@ -14,6 +18,7 @@
|
||||
"success": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"expiration": "",
|
||||
"button": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": {
|
||||
"stop": "",
|
||||
"start": ""
|
||||
},
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -254,6 +262,14 @@
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": {
|
||||
"stop": "",
|
||||
"start": ""
|
||||
},
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -363,5 +379,8 @@
|
||||
"message": "",
|
||||
"stop": "",
|
||||
"ignore": ""
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "An error occurred while loading the page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio is still in early development — your input matters!",
|
||||
"context": "Product under development — your input matters!",
|
||||
"cta": "Share your feedback"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Recording unavailable",
|
||||
"body": "This recording could not be found or was deleted after its 7-day validity period."
|
||||
"body": "This recording could not be found or was deleted."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Recording expired",
|
||||
"body": "This recording was deleted on {{date}}."
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Authentication required",
|
||||
@@ -14,6 +18,7 @@
|
||||
"success": {
|
||||
"title": "Your recording is ready!",
|
||||
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
||||
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
|
||||
"button": "Download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"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"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcription Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the transcription. Please try again in a moment.",
|
||||
"start": "We were unable to start the transcription. Please try again in a moment."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Recording in progress…",
|
||||
"body": "You will receive the result by email once the recording is complete.",
|
||||
"button": "Stop recording"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Recording Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the recording. Please try again in a moment.",
|
||||
"start": "We were unable to start the recording. Please try again in a moment."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
||||
"stop": "Stop presenting",
|
||||
"ignore": "Ignore"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "{{name}}'s screen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio est en pleine construction — votre avis compte !",
|
||||
"context": "Produit en cours de développement — votre avis compte !",
|
||||
"cta": "Partagez votre avis"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Enregistrement indisponible",
|
||||
"body": "Cet enregistrement est introuvable ou a été supprimé après sa période de validité de 7 jours."
|
||||
"body": "Cet enregistrement est introuvable ou a été supprimé."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Enregistrement expiré",
|
||||
"body": "Cet enregistrement a été supprimé le {{date}}."
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Authentification requise",
|
||||
@@ -14,6 +18,7 @@
|
||||
"success": {
|
||||
"title": "Votre enregistrement est prêt !",
|
||||
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
||||
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
|
||||
"button": "Télécharger"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"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"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de transcription",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper la transcription. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer la transcription. Veuillez réessayer dans quelques instants."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Enregistrement en cours …",
|
||||
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de l'enregistrement",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper l'enregistrement. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer l'enregistrement. Veuillez réessayer dans quelques instants."
|
||||
},
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
|
||||
"stop": "Arrêter la présentation",
|
||||
"ignore": "Ignorer"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "Écran de {{name}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"heading": "Er is een fout opgetreden bij het laden van de pagina"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Visio is nog in vroege ontwikkeling - uw input is belangrijk!",
|
||||
"context": "Product in ontwikkeling - uw input is belangrijk!",
|
||||
"cta": "Deel uw feedback"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Opname niet beschikbaar",
|
||||
"body": "Deze opname is niet gevonden of is verwijderd na de geldigheidsperiode van 7 dagen."
|
||||
"body": "Deze opname is niet gevonden of is verwijderd."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Opname verlopen",
|
||||
"body": "Deze opname is verwijderd op {{date}}."
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Authenticatie vereist",
|
||||
@@ -14,6 +18,7 @@
|
||||
"success": {
|
||||
"title": "Je opname is klaar!",
|
||||
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
|
||||
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
|
||||
"button": "Downloaden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
"heading": "Word betatester",
|
||||
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Aanmelden"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcriptie mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de transcriptie niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de transcriptie niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
@@ -255,6 +263,14 @@
|
||||
"heading": "Opname bezig …",
|
||||
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
|
||||
"button": "Opname stoppen"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Opname mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de opname niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de opname niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -364,5 +380,8 @@
|
||||
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
|
||||
"stop": "Stop met presenteren",
|
||||
"ignore": "Negeren"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "{{name}} scherm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export const GRIST_FEEDBACKS_FORM =
|
||||
'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26' as const
|
||||
|
||||
export const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Map frontend language codes to backend language codes
|
||||
|
||||
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl'
|
||||
|
||||
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
en: 'en-us',
|
||||
fr: 'fr-fr',
|
||||
nl: 'nl-nl',
|
||||
}
|
||||
|
||||
export const convertToBackendLanguage = (
|
||||
frontendLang: string = 'fr'
|
||||
): BackendLanguage => {
|
||||
return frontendToBackendMap[frontendLang as FrontendLanguage]
|
||||
}
|
||||
@@ -55,13 +55,13 @@ backend:
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_REGION_NAME: local
|
||||
RECORDING_ENABLE: True
|
||||
RECORDING_VERIFY_SSL: True
|
||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||
|
||||
@@ -83,7 +83,6 @@ backend:
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_REGION_NAME: local
|
||||
RECORDING_ENABLE: True
|
||||
RECORDING_VERIFY_SSL: True
|
||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{- $envVars := include "meet.common.env" (list . .Values.backend) -}}
|
||||
{{- $fullName := include "meet.backend.fullname" . -}}
|
||||
{{- $component := "backend" -}}
|
||||
{{- $component := "backend-createsuperuser" -}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{{- $envVars := include "meet.common.env" (list . .Values.backend) -}}
|
||||
{{- $fullName := include "meet.backend.fullname" . -}}
|
||||
{{- $component := "backend" -}}
|
||||
{{- $component := "backend-migrate" -}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
@@ -23,13 +23,18 @@
|
||||
{% blocktrans %}
|
||||
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download.
|
||||
{% endblocktrans %}
|
||||
{% if recording_expiration_days %}
|
||||
{% blocktrans with days=recording_expiration_days %}
|
||||
The recording will expire in {{days}} days.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</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>
|
||||
<li>{% blocktrans %}Click the "Open" button below {% endblocktrans %}</li>
|
||||
<li>{% blocktrans %}Use the "Download" button in the interface {% endblocktrans %}</li>
|
||||
<li>{% blocktrans %}Save the file to your preferred location{% endblocktrans %}</li>
|
||||
</ol>
|
||||
</mj-text>
|
||||
<mj-button
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.21",
|
||||
"type": "module",
|
||||
"main": "./dist/visio-sdk.umd.cjs",
|
||||
"module": "./dist/visio-sdk.js",
|
||||
@@ -46,7 +46,7 @@
|
||||
"sass": "1.83.4",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "5.4.16",
|
||||
"vite": "5.4.18",
|
||||
"vite-plugin-dts": "3.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
@@ -620,7 +620,7 @@
|
||||
},
|
||||
"library": {
|
||||
"name": "@gouvfr-lasuite/visio-sdk",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.21",
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
@@ -641,7 +641,7 @@
|
||||
"prettier": "3.4.2",
|
||||
"sass": "1.83.4",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "5.4.16",
|
||||
"vite": "5.4.18",
|
||||
"vite-plugin-dts": "3.9.1"
|
||||
}
|
||||
},
|
||||
@@ -7296,9 +7296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
|
||||
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
|
||||
"version": "5.4.18",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
|
||||
"integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "0.1.18"
|
||||
version = "0.1.21"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user