mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
76 Commits
v-hackathon
...
v0.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| c4206b4b24 | |||
| 732df0768c | |||
| 8a96b6939a | |||
| e78b8c69fe | |||
| 6e49ab3230 | |||
| 200e2d3c2f | |||
| 4ffef3f94a | |||
| 8e62f2ecd3 | |||
| d024fb1b95 | |||
| 7e49f0f661 | |||
| fb8c0fd1b5 | |||
| b07e4c58b4 | |||
| e6bbf2d3c8 | |||
| 28899a2aaf | |||
| a5bc66a921 | |||
| adb09410da | |||
| 061f12e7e2 | |||
| 24e819a533 | |||
| 5c48ac100a | |||
| 4434f0265d | |||
| 0522696842 | |||
| 8516782d79 | |||
| b06880be15 | |||
| 5de9cec688 | |||
| 7dfd86873c | |||
| 7cee409d22 | |||
| 7ddc448c88 | |||
| 6fcb69bd3c | |||
| 6aed4cb751 | |||
| f433f59d3f | |||
| fba9910705 | |||
| dcba3330f7 | |||
| 82bb5f0f8b | |||
| 0fd06ef6c0 | |||
| bd4dec6f27 | |||
| a987830fb3 | |||
| 7f09636791 | |||
| 3460ec8808 | |||
| 86cda75b39 | |||
| 7afa165013 | |||
| e11bdc6d28 | |||
| 8309545ec6 | |||
| 840033fcbc | |||
| 28ca2d6c37 | |||
| 4e77458116 | |||
| d4532eeb64 | |||
| b84628ee95 | |||
| f6f1222f47 | |||
| 7278613b20 | |||
| d370a4db10 | |||
| c1bc379744 | |||
| 5ef6359b7c | |||
| 04d76acce5 | |||
| eeb71f90bc | |||
| 5db4b09106 | |||
| 11cd85d4eb | |||
| ccbeeba68f | |||
| f3ea0fca71 | |||
| cb4acc32e5 | |||
| 60f5c8486b | |||
| ed3a26d449 | |||
| cb4c058c5d | |||
| c504b5262b | |||
| 3b3816b333 | |||
| 15e922f9df | |||
| b69f777e5a | |||
| 67d004fbda | |||
| 4449b578bd | |||
| 1f0d2ce335 | |||
| 0d1d0662ee | |||
| 0056b4cc29 | |||
| d7892cde9f | |||
| 372db49e94 | |||
| f7ed70dc9c | |||
| 427b23ca80 | |||
| 99a1efc538 |
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.12.6-alpine3.20 as base
|
FROM python:3.12.6-alpine3.20 AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
|||||||
apk upgrade
|
apk upgrade
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base as back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 as mail-builder
|
FROM node:20 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ RUN yarn install --frozen-lockfile && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
FROM base as link-collector
|
FROM base AS link-collector
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
@@ -58,7 +58,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
|||||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
||||||
|
|
||||||
# ---- Core application image ----
|
# ---- Core application image ----
|
||||||
FROM base as core
|
FROM base AS core
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ WORKDIR /app
|
|||||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||||
|
|
||||||
# ---- Development image ----
|
# ---- Development image ----
|
||||||
FROM core as backend-development
|
FROM core AS backend-development
|
||||||
|
|
||||||
# Switch back to the root user to install development dependencies
|
# Switch back to the root user to install development dependencies
|
||||||
USER root:root
|
USER root:root
|
||||||
@@ -119,7 +119,7 @@ ENV DB_HOST=postgresql \
|
|||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core as backend-production
|
FROM core AS backend-production
|
||||||
|
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
|
|||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt: ## start the kubernetes cluster using kind
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
|
kubectl config set-context --current --namespace=meet
|
||||||
tilt up -f ./bin/Tiltfile
|
tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,41 @@ data:
|
|||||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF | kubectl apply -f -
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: coredns
|
||||||
|
namespace: kube-system
|
||||||
|
data:
|
||||||
|
Corefile: |
|
||||||
|
.:53 {
|
||||||
|
errors
|
||||||
|
health {
|
||||||
|
lameduck 5s
|
||||||
|
}
|
||||||
|
ready
|
||||||
|
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||||
|
pods insecure
|
||||||
|
fallthrough in-addr.arpa ip6.arpa
|
||||||
|
ttl 30
|
||||||
|
}
|
||||||
|
prometheus :9153
|
||||||
|
forward . /etc/resolv.conf {
|
||||||
|
max_concurrent 1000
|
||||||
|
}
|
||||||
|
rewrite stop {
|
||||||
|
name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto
|
||||||
|
}
|
||||||
|
cache 30
|
||||||
|
loop
|
||||||
|
reload
|
||||||
|
loadbalance
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
kubectl -n kube-system rollout restart deployments/coredns
|
||||||
|
|
||||||
echo "6. Install ingress-nginx"
|
echo "6. Install ingress-nginx"
|
||||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgresql:
|
postgresql:
|
||||||
@@ -447,10 +447,10 @@ max-bool-expr=5
|
|||||||
max-branches=12
|
max-branches=12
|
||||||
|
|
||||||
# Maximum number of locals for function / method body
|
# Maximum number of locals for function / method body
|
||||||
max-locals=15
|
max-locals=20
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
# Maximum number of parents for a class (see R0901).
|
||||||
max-parents=7
|
max-parents=10
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
# Maximum number of public methods for a class (see R0904).
|
||||||
max-public-methods=20
|
max-public-methods=20
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
(
|
||||||
|
_("Personal info"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"sub",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"language",
|
||||||
|
"timezone",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
_("Permissions"),
|
_("Permissions"),
|
||||||
{
|
{
|
||||||
@@ -52,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"sub",
|
"sub",
|
||||||
"admin_email",
|
"admin_email",
|
||||||
"email",
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_staff",
|
"is_staff",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
@@ -60,9 +74,24 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
ordering = (
|
||||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
"is_active",
|
||||||
search_fields = ("id", "sub", "admin_email", "email")
|
"-is_superuser",
|
||||||
|
"-is_staff",
|
||||||
|
"-is_device",
|
||||||
|
"-updated_at",
|
||||||
|
"full_name",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"sub",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessInline(admin.TabularInline):
|
class ResourceAccessInline(admin.TabularInline):
|
||||||
@@ -77,3 +106,18 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
"""Room admin interface declaration."""
|
"""Room admin interface declaration."""
|
||||||
|
|
||||||
inlines = (ResourceAccessInline,)
|
inlines = (ResourceAccessInline,)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingAccessInline(admin.TabularInline):
|
||||||
|
"""Inline admin class for recording accesses."""
|
||||||
|
|
||||||
|
model = models.RecordingAccess
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Recording)
|
||||||
|
class RecordingAdmin(admin.ModelAdmin):
|
||||||
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
|
inlines = (RecordingAccessInline,)
|
||||||
|
list_display = ("id", "status", "room", "created_at", "worker_id")
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""
|
|
||||||
Meet analytics class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from june import analytics as jAnalytics
|
|
||||||
|
|
||||||
|
|
||||||
class Analytics:
|
|
||||||
"""Analytics integration
|
|
||||||
|
|
||||||
This class wraps the June analytics code to avoid coupling our code directly
|
|
||||||
with this third-party library. By doing so, we create a generic interface
|
|
||||||
for analytics that can be easily modified or replaced in the future.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
key = getattr(settings, "ANALYTICS_KEY", None)
|
|
||||||
|
|
||||||
if key is not None:
|
|
||||||
jAnalytics.write_key = key
|
|
||||||
|
|
||||||
self._enabled = key is not None
|
|
||||||
|
|
||||||
def _is_anonymous_user(self, user):
|
|
||||||
"""Check if the user is anonymous."""
|
|
||||||
return user is None or user.is_anonymous
|
|
||||||
|
|
||||||
def identify(self, user, **kwargs):
|
|
||||||
"""Identify a user"""
|
|
||||||
|
|
||||||
if self._is_anonymous_user(user) or not self._enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
traits = kwargs.pop("traits", {})
|
|
||||||
traits.update({"email": user.email_anonymized})
|
|
||||||
|
|
||||||
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
|
|
||||||
|
|
||||||
def track(self, user, **kwargs):
|
|
||||||
"""Track an event"""
|
|
||||||
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
event_data = {}
|
|
||||||
if self._is_anonymous_user(user):
|
|
||||||
event_data["anonymous_id"] = str(uuid.uuid4())
|
|
||||||
else:
|
|
||||||
event_data["user_id"] = user.sub
|
|
||||||
|
|
||||||
jAnalytics.track(**event_data, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
analytics = Analytics()
|
|
||||||
@@ -37,6 +37,10 @@ def get_frontend_configuration(request):
|
|||||||
"""Returns the frontend configuration dict as configured in settings."""
|
"""Returns the frontend configuration dict as configured in settings."""
|
||||||
frontend_configuration = {
|
frontend_configuration = {
|
||||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||||
|
"recording": {
|
||||||
|
"is_enabled": settings.RECORDING_ENABLE,
|
||||||
|
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Permission handlers for the Meet core app."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
@@ -65,15 +67,11 @@ class RoomPermissions(permissions.BasePermission):
|
|||||||
return obj.is_administrator(user)
|
return obj.is_administrator(user)
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessPermission(permissions.BasePermission):
|
class ResourceAccessPermission(IsAuthenticated):
|
||||||
"""
|
"""
|
||||||
Permissions for a room that can only be updated by room administrators.
|
Permissions for a room that can only be updated by room administrators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Only allow authenticated users."""
|
|
||||||
return request.user.is_authenticated
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""
|
"""
|
||||||
Check that the logged-in user is administrator of the linked room.
|
Check that the logged-in user is administrator of the linked room.
|
||||||
@@ -83,3 +81,41 @@ class ResourceAccessPermission(permissions.BasePermission):
|
|||||||
return obj.user == user
|
return obj.user == user
|
||||||
|
|
||||||
return obj.resource.is_administrator(user)
|
return obj.resource.is_administrator(user)
|
||||||
|
|
||||||
|
|
||||||
|
class HasAbilityPermission(IsAuthenticated):
|
||||||
|
"""Permission class for access objects."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
return obj.get_abilities(request.user).get(view.action, False)
|
||||||
|
|
||||||
|
|
||||||
|
class HasPrivilegesOnRoom(IsAuthenticated):
|
||||||
|
"""Check if user has privileges on a given room."""
|
||||||
|
|
||||||
|
message = "You must have privileges to start a recording."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Determine if user has privileges on room."""
|
||||||
|
return obj.is_owner(request.user) or obj.is_administrator(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class IsRecordingEnabled(permissions.BasePermission):
|
||||||
|
"""Check if the recording feature is enabled."""
|
||||||
|
|
||||||
|
message = "Access denied, recording is disabled."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Determine if access is allowed based on settings."""
|
||||||
|
return settings.RECORDING_ENABLE
|
||||||
|
|
||||||
|
|
||||||
|
class IsStorageEventEnabled(permissions.BasePermission):
|
||||||
|
"""Check if the storage event feature is enabled."""
|
||||||
|
|
||||||
|
message = "Access denied, storage event is disabled."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Determine if access is allowed based on settings."""
|
||||||
|
return settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email"]
|
fields = ["id", "email", "full_name", "short_name"]
|
||||||
read_only_fields = ["id", "email"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessSerializerMixin:
|
class ResourceAccessSerializerMixin:
|
||||||
@@ -87,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
|
|||||||
user = UserSerializer(read_only=True)
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ListRoomSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize Room model for a list API endpoint."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Room
|
||||||
|
fields = ["id", "name", "slug", "is_public"]
|
||||||
|
read_only_fields = ["id", "slug"]
|
||||||
|
|
||||||
|
|
||||||
class RoomSerializer(serializers.ModelSerializer):
|
class RoomSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize Room model for the API."""
|
"""Serialize Room model for the API."""
|
||||||
|
|
||||||
@@ -121,8 +130,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
del output["configuration"]
|
del output["configuration"]
|
||||||
|
|
||||||
if role is not None or instance.is_public:
|
if role is not None or instance.is_public:
|
||||||
slug = f"{instance.id!s}".replace("-", "")
|
slug = f"{instance.id!s}"
|
||||||
|
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
@@ -136,3 +144,36 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
output["is_administrable"] = is_admin
|
output["is_administrable"] = is_admin
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize Recording for the API."""
|
||||||
|
|
||||||
|
room = ListRoomSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Recording
|
||||||
|
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class StartRecordingSerializer(serializers.Serializer):
|
||||||
|
"""Validate start recording requests."""
|
||||||
|
|
||||||
|
mode = serializers.ChoiceField(
|
||||||
|
choices=models.RecordingModeChoices.choices,
|
||||||
|
required=True,
|
||||||
|
error_messages={
|
||||||
|
"required": "Recording mode is required.",
|
||||||
|
"invalid_choice": "Invalid recording mode. Choose between "
|
||||||
|
"screen_recording or transcript.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Not implemented as this is a validation-only serializer."""
|
||||||
|
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Not implemented as this is a validation-only serializer."""
|
||||||
|
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -14,17 +15,41 @@ from rest_framework import (
|
|||||||
pagination,
|
pagination,
|
||||||
viewsets,
|
viewsets,
|
||||||
)
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
exceptions as drf_exceptions,
|
||||||
|
)
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
status as drf_status,
|
||||||
|
)
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.recording.event.authentication import StorageEventAuthentication
|
||||||
|
from core.recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
from core.recording.event.parsers import get_parser
|
||||||
|
from core.recording.worker.exceptions import (
|
||||||
|
RecordingStartError,
|
||||||
|
RecordingStopError,
|
||||||
|
)
|
||||||
|
from core.recording.worker.factories import (
|
||||||
|
get_worker_service,
|
||||||
|
)
|
||||||
|
from core.recording.worker.mediator import (
|
||||||
|
WorkerServiceMediator,
|
||||||
|
)
|
||||||
|
|
||||||
from ..analytics import analytics
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -186,13 +211,6 @@ class RoomViewSet(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
|
||||||
analytics.track(
|
|
||||||
user=self.request.user,
|
|
||||||
event="Get Room",
|
|
||||||
properties={"slug": instance.slug},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Http404:
|
except Http404:
|
||||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||||
raise
|
raise
|
||||||
@@ -241,12 +259,87 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
analytics.track(
|
@decorators.action(
|
||||||
user=self.request.user,
|
detail=True,
|
||||||
event="Create Room",
|
methods=["post"],
|
||||||
properties={
|
url_path="start-recording",
|
||||||
"slug": room.slug,
|
permission_classes=[
|
||||||
},
|
permissions.HasPrivilegesOnRoom,
|
||||||
|
permissions.IsRecordingEnabled,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Start recording a room."""
|
||||||
|
|
||||||
|
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||||
|
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = serializer.validated_data["mode"]
|
||||||
|
room = self.get_object()
|
||||||
|
|
||||||
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
|
recording = models.Recording.objects.create(room=room, mode=mode)
|
||||||
|
|
||||||
|
models.RecordingAccess.objects.create(
|
||||||
|
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||||
|
)
|
||||||
|
|
||||||
|
worker_service = get_worker_service(mode=recording.mode)
|
||||||
|
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker_manager.start(recording)
|
||||||
|
except RecordingStartError:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": f"Recording failed to start for room {room.slug}"},
|
||||||
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": f"Recording successfully started for room {room.slug}"},
|
||||||
|
status=drf_status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="stop-recording",
|
||||||
|
permission_classes=[
|
||||||
|
permissions.HasPrivilegesOnRoom,
|
||||||
|
permissions.IsRecordingEnabled,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Stop room recording."""
|
||||||
|
|
||||||
|
room = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(
|
||||||
|
room=room, status=models.RecordingStatusChoices.ACTIVE
|
||||||
|
)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound(
|
||||||
|
"No active recording found for this room."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
worker_service = get_worker_service(mode=recording.mode)
|
||||||
|
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker_manager.stop(recording)
|
||||||
|
except RecordingStopError:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": f"Recording failed to stop for room {room.slug}"},
|
||||||
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": f"Recording stopped for room {room.slug}."}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,3 +386,71 @@ class ResourceAccessViewSet(
|
|||||||
permission_classes = [permissions.ResourceAccessPermission]
|
permission_classes = [permissions.ResourceAccessPermission]
|
||||||
queryset = models.ResourceAccess.objects.all()
|
queryset = models.ResourceAccess.objects.all()
|
||||||
serializer_class = serializers.ResourceAccessSerializer
|
serializer_class = serializers.ResourceAccessSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingViewSet(
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
API endpoints to access and perform actions on recordings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pagination_class = Pagination
|
||||||
|
permission_classes = [permissions.HasAbilityPermission]
|
||||||
|
queryset = models.Recording.objects.all()
|
||||||
|
serializer_class = serializers.RecordingSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Restrict recordings to the user's ones."""
|
||||||
|
user = self.request.user
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
||||||
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="storage-hook",
|
||||||
|
authentication_classes=[StorageEventAuthentication],
|
||||||
|
permission_classes=[permissions.IsStorageEventEnabled],
|
||||||
|
)
|
||||||
|
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Handle incoming storage hook events for recordings."""
|
||||||
|
|
||||||
|
parser = get_parser()
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording_id = parser.get_recording_id(request.data)
|
||||||
|
|
||||||
|
except ParsingEventDataError as e:
|
||||||
|
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
||||||
|
|
||||||
|
except InvalidBucketError as e:
|
||||||
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||||
|
|
||||||
|
except InvalidFileTypeError as e:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": f"Ignore this file type, {e}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(id=recording_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||||
|
|
||||||
|
if not recording.is_savable():
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||||
|
" in an error state or has already been saved."
|
||||||
|
)
|
||||||
|
|
||||||
|
recording.status = models.RecordingStatusChoices.SAVED
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": "Event processed."},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -10,8 +11,6 @@ from mozilla_django_oidc.auth import (
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
from ..analytics import analytics
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
@@ -68,36 +67,76 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||||
sub = user_info.get("sub")
|
sub = user_info.get("sub")
|
||||||
|
|
||||||
if sub is None:
|
if not sub:
|
||||||
raise SuspiciousOperation(
|
raise SuspiciousOperation(
|
||||||
_("User info contained no recognizable user identification")
|
_("User info contained no recognizable user identification")
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
email = user_info.get("email")
|
||||||
user = User.objects.get(sub=sub)
|
user = self.get_existing_user(sub, email)
|
||||||
except User.DoesNotExist:
|
|
||||||
if self.get_settings("OIDC_CREATE_USER", True):
|
|
||||||
user = self.create_user(user_info)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
|
|
||||||
analytics.identify(user=user)
|
claims = {
|
||||||
return user
|
"email": email,
|
||||||
|
"full_name": self.compute_full_name(user_info),
|
||||||
def create_user(self, claims):
|
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||||
"""Return a newly created User instance."""
|
}
|
||||||
|
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||||
sub = claims.get("sub")
|
user = User.objects.create(
|
||||||
|
sub=sub,
|
||||||
if sub is None:
|
password="!", # noqa: S106
|
||||||
raise SuspiciousOperation(
|
**claims,
|
||||||
_("Claims contained no recognizable user identification")
|
|
||||||
)
|
)
|
||||||
|
elif not user:
|
||||||
|
return None
|
||||||
|
|
||||||
user = User.objects.create(
|
if not user.is_active:
|
||||||
sub=sub,
|
raise SuspiciousOperation(_("User account is disabled"))
|
||||||
email=claims.get("email"),
|
|
||||||
password="!", # noqa: S106
|
self.update_user_if_needed(user, claims)
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def get_existing_user(self, sub, email):
|
||||||
|
"""Fetch existing user by sub or email."""
|
||||||
|
try:
|
||||||
|
return User.objects.get(sub=sub)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||||
|
try:
|
||||||
|
return User.objects.get(email__iexact=email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except User.MultipleObjectsReturned as e:
|
||||||
|
raise SuspiciousOperation(
|
||||||
|
_("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)
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ from mozilla_django_oidc.views import (
|
|||||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..analytics import analytics
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||||
@@ -100,10 +98,6 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
|||||||
|
|
||||||
logout_url = self.redirect_url
|
logout_url = self.redirect_url
|
||||||
|
|
||||||
analytics.track(
|
|
||||||
user=request.user,
|
|
||||||
event="Signed Out",
|
|
||||||
)
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
logout_url = self.construct_oidc_logout_url(request)
|
logout_url = self.construct_oidc_logout_url(request)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
|
full_name = factory.Faker("name")
|
||||||
|
short_name = factory.Faker("first_name")
|
||||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||||
password = make_password("password")
|
password = make_password("password")
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||||
|
|
||||||
@@ -45,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
else:
|
else:
|
||||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake resource user accesses for testing."""
|
"""Create fake resource user accesses for testing."""
|
||||||
@@ -65,3 +70,52 @@ class RoomFactory(ResourceFactory):
|
|||||||
|
|
||||||
name = factory.Faker("catch_phrase")
|
name = factory.Faker("catch_phrase")
|
||||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake recording for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Recording
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
|
room = factory.SubFactory(RoomFactory)
|
||||||
|
status = models.RecordingStatusChoices.INITIATED
|
||||||
|
mode = models.RecordingModeChoices.SCREEN_RECORDING
|
||||||
|
worker_id = None
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def users(self, create, extracted, **kwargs):
|
||||||
|
"""Add users to recording from a given list of users with or without roles."""
|
||||||
|
if create and extracted:
|
||||||
|
for item in extracted:
|
||||||
|
if isinstance(item, models.User):
|
||||||
|
UserRecordingAccessFactory(recording=self, user=item)
|
||||||
|
else:
|
||||||
|
UserRecordingAccessFactory(
|
||||||
|
recording=self, user=item[0], role=item[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake recording user accesses for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.RecordingAccess
|
||||||
|
|
||||||
|
recording = factory.SubFactory(RecordingFactory)
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake recording team accesses for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.RecordingAccess
|
||||||
|
|
||||||
|
recording = factory.SubFactory(RecordingFactory)
|
||||||
|
team = factory.Sequence(lambda n: f"team{n}")
|
||||||
|
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-11-06 14:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0004_alter_user_language'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Recording',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||||
|
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
|
||||||
|
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Recording',
|
||||||
|
'verbose_name_plural': 'Recordings',
|
||||||
|
'db_table': 'meet_recording',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RecordingAccess',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||||
|
('team', models.CharField(blank=True, max_length=100)),
|
||||||
|
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||||
|
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Recording/user relation',
|
||||||
|
'verbose_name_plural': 'Recording/user relations',
|
||||||
|
'db_table': 'meet_recording_access',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='recording',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='recordingaccess',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='recordingaccess',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='recordingaccess',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import Count
|
||||||
|
from core.models import RoleChoices
|
||||||
|
|
||||||
|
def merge_duplicate_user_accounts(apps, schema_editor):
|
||||||
|
"""Merge user accounts that share the same email address.
|
||||||
|
|
||||||
|
Historical Context:
|
||||||
|
Previously, ProConnect authentication could return users with the same email
|
||||||
|
but different sub, leading to duplicate user accounts. While the application
|
||||||
|
now prevents this scenario, this migration is needed to clean up existing
|
||||||
|
duplicate accounts to ensure users can continue to connect without being blocked
|
||||||
|
by unique email constraints.
|
||||||
|
|
||||||
|
Performance of this migration is poor, this implementation prioritizes readability
|
||||||
|
and maintainability. Consider refactoring this code to avoid individual db queries
|
||||||
|
on each iteration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
User = apps.get_model('core', 'User')
|
||||||
|
ResourceAccess = apps.get_model('core', 'ResourceAccess')
|
||||||
|
|
||||||
|
emails_with_duplicates = (
|
||||||
|
User.objects.values('email')
|
||||||
|
.annotate(count=Count('id'))
|
||||||
|
.filter(count__gt=1)
|
||||||
|
.values_list('email', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
for email in emails_with_duplicates:
|
||||||
|
# Keep the oldest user
|
||||||
|
primary_user = User.objects.filter(email=email).order_by('created_at').first()
|
||||||
|
duplicate_user_accounts = User.objects.filter(email=email).exclude(id=primary_user.id)
|
||||||
|
|
||||||
|
# Get IDs of duplicate accounts to be merged
|
||||||
|
duplicate_account_ids = list(duplicate_user_accounts.values_list('id', flat=True))
|
||||||
|
resource_accesses_to_transfer = ResourceAccess.objects.filter(user_id__in=duplicate_account_ids)
|
||||||
|
|
||||||
|
# Transfer resource access permissions to primary user
|
||||||
|
# This process handles role hierarchy where:
|
||||||
|
# OWNER > ADMIN > MEMBER
|
||||||
|
for resource_access in resource_accesses_to_transfer:
|
||||||
|
|
||||||
|
# Determine if primary user already has access to this resource
|
||||||
|
existing_primary_access = ResourceAccess.objects.filter(
|
||||||
|
user_id=primary_user.id,
|
||||||
|
resource_id=resource_access.resource.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_primary_access:
|
||||||
|
# Skip if primary user is already OWNER as it's the highest privilege level
|
||||||
|
# No need to modify or downgrade owner access
|
||||||
|
if existing_primary_access.role == RoleChoices.OWNER:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if primary user already has the exact same role
|
||||||
|
# No need to update when roles match
|
||||||
|
elif existing_primary_access.role == resource_access.role:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if new role is MEMBER since user already has base access
|
||||||
|
# All existing access includes at least MEMBER privileges
|
||||||
|
elif resource_access.role == RoleChoices.MEMBER:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update the role only if it represents a higher privilege level
|
||||||
|
# Preserves existing access record while updating the role
|
||||||
|
existing_primary_access.role = resource_access.role
|
||||||
|
existing_primary_access.save()
|
||||||
|
else:
|
||||||
|
# Transfer access to primary user
|
||||||
|
resource_access.user_id = primary_user.id
|
||||||
|
resource_access.save()
|
||||||
|
|
||||||
|
# Delete duplicate accounts - CASCADE will automatically remove any untransferred
|
||||||
|
# ResourceAccess records and other related data for these users
|
||||||
|
duplicate_user_accounts.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0005_recording_recordingaccess_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(merge_duplicate_user_accounts, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-11-12 10:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0006_merge_duplicate_users'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recording',
|
||||||
|
name='mode',
|
||||||
|
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the mode of recording being called.', max_length=20, verbose_name='Recording mode'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-11-13 09:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0007_recording_mode'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='full_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='short_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
|
||||||
|
)
|
||||||
|
]
|
||||||
+284
-9
@@ -4,6 +4,7 @@ Declare and configure the models for the Meet core application
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import models as auth_models
|
from django.contrib.auth import models as auth_models
|
||||||
@@ -38,6 +39,46 @@ class RoleChoices(models.TextChoices):
|
|||||||
return role == cls.OWNER
|
return role == cls.OWNER
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingStatusChoices(models.TextChoices):
|
||||||
|
"""Enumeration of possible states for a recording operation."""
|
||||||
|
|
||||||
|
INITIATED = "initiated", _("Initiated")
|
||||||
|
ACTIVE = "active", _("Active")
|
||||||
|
STOPPED = "stopped", _("Stopped")
|
||||||
|
SAVED = "saved", _("Saved")
|
||||||
|
ABORTED = "aborted", _("Aborted")
|
||||||
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_final(cls, status):
|
||||||
|
"""Determine if the recording status represents a final state.
|
||||||
|
|
||||||
|
A final status indicates the recording flow has completed, either
|
||||||
|
successfully or unsuccessfully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return status in {
|
||||||
|
cls.STOPPED,
|
||||||
|
cls.SAVED,
|
||||||
|
cls.ABORTED,
|
||||||
|
cls.FAILED_TO_START,
|
||||||
|
cls.FAILED_TO_STOP,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_unsuccessful(cls, status):
|
||||||
|
"""Determine if the recording status represents an unsuccessful state."""
|
||||||
|
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingModeChoices(models.TextChoices):
|
||||||
|
"""Recording mode choices."""
|
||||||
|
|
||||||
|
SCREEN_RECORDING = "screen_recording", _("SCREEN_RECORDING")
|
||||||
|
TRANSCRIPT = "transcript", _("TRANSCRIPT")
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
"""
|
"""
|
||||||
Serves as an abstract base model for other models, ensuring that records are validated
|
Serves as an abstract base model for other models, ensuring that records are validated
|
||||||
@@ -100,11 +141,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||||
|
|
||||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||||
# stores the email used by staff users to login to the admin site
|
# stores the email used by staff users to log in to the admin site
|
||||||
admin_email = models.EmailField(
|
admin_email = models.EmailField(
|
||||||
_("admin email address"), unique=True, blank=True, null=True
|
_("admin email address"), unique=True, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||||
|
short_name = models.CharField(
|
||||||
|
_("short name"), max_length=100, null=True, blank=True
|
||||||
|
)
|
||||||
language = models.CharField(
|
language = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||||
@@ -163,18 +207,39 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@property
|
|
||||||
def email_anonymized(self):
|
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
||||||
"""Anonymize the email address by replacing the local part with asterisks."""
|
"""
|
||||||
if not self.email:
|
Get all roles assigned to a user for a specific resource, including team-based roles.
|
||||||
return ""
|
|
||||||
return f"***@{self.email.split('@')[1]}"
|
Args:
|
||||||
|
resource: The resource to check permissions for
|
||||||
|
user: The user to get roles for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role strings assigned to the user
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use pre-annotated roles if available from viewset optimization
|
||||||
|
if hasattr(resource, "user_roles"):
|
||||||
|
return resource.user_roles or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
return list(
|
||||||
|
resource.accesses.filter_user(user)
|
||||||
|
.values_list("role", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
except (IndexError, models.ObjectDoesNotExist):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class Resource(BaseModel):
|
class Resource(BaseModel):
|
||||||
"""Model to define access control"""
|
"""Model to define access control"""
|
||||||
|
|
||||||
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
|
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
||||||
users = models.ManyToManyField(
|
users = models.ManyToManyField(
|
||||||
User,
|
User,
|
||||||
through="ResourceAccess",
|
through="ResourceAccess",
|
||||||
@@ -325,3 +390,213 @@ class Room(Resource):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccessManager(models.Manager):
|
||||||
|
"""Base manager for handling resource access control."""
|
||||||
|
|
||||||
|
def filter_user(self, user):
|
||||||
|
"""Filter accesses for a given user, including both direct and team-based access."""
|
||||||
|
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccess(BaseModel):
|
||||||
|
"""Base model for accesses to handle resources."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
team = models.CharField(max_length=100, blank=True)
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = BaseAccessManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def _get_abilities(self, resource, user):
|
||||||
|
"""
|
||||||
|
Compute and return abilities for a given user taking into account
|
||||||
|
the current state of the object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = get_resource_roles(resource, user)
|
||||||
|
|
||||||
|
is_owner = RoleChoices.OWNER in roles
|
||||||
|
has_privileges = is_owner or RoleChoices.ADMIN in roles
|
||||||
|
|
||||||
|
# Default values for unprivileged users
|
||||||
|
set_role_to = set()
|
||||||
|
can_delete = False
|
||||||
|
|
||||||
|
# Special handling when modifying an owner's access
|
||||||
|
if self.role == RoleChoices.OWNER:
|
||||||
|
# Prevent orphaning the resource
|
||||||
|
can_delete = (
|
||||||
|
is_owner
|
||||||
|
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||||
|
)
|
||||||
|
if can_delete:
|
||||||
|
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
|
||||||
|
elif has_privileges:
|
||||||
|
can_delete = True
|
||||||
|
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
|
||||||
|
if is_owner:
|
||||||
|
set_role_to.add(RoleChoices.OWNER)
|
||||||
|
|
||||||
|
# Remove the current role as we don't want to propose it as an option
|
||||||
|
set_role_to.discard(self.role)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"destroy": can_delete,
|
||||||
|
"update": bool(set_role_to),
|
||||||
|
"partial_update": bool(set_role_to),
|
||||||
|
"retrieve": bool(roles),
|
||||||
|
"set_role_to": sorted(r.value for r in set_role_to),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Recording(BaseModel):
|
||||||
|
"""Model for recordings that take place in a room"""
|
||||||
|
|
||||||
|
room = models.ForeignKey(
|
||||||
|
Room,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="recordings",
|
||||||
|
verbose_name=_("Room"),
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=RecordingStatusChoices.choices,
|
||||||
|
default=RecordingStatusChoices.INITIATED,
|
||||||
|
)
|
||||||
|
worker_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Worker ID"),
|
||||||
|
help_text=_(
|
||||||
|
"Enter an identifier for the worker recording."
|
||||||
|
"This ID is retained even when the worker stops, allowing for easy tracking."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mode = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=RecordingModeChoices.choices,
|
||||||
|
default=RecordingModeChoices.SCREEN_RECORDING,
|
||||||
|
verbose_name=_("Recording mode"),
|
||||||
|
help_text=_("Defines the mode of recording being called."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "meet_recording"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
verbose_name = _("Recording")
|
||||||
|
verbose_name_plural = _("Recordings")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["room"],
|
||||||
|
condition=models.Q(
|
||||||
|
status__in=[
|
||||||
|
RecordingStatusChoices.ACTIVE,
|
||||||
|
RecordingStatusChoices.INITIATED,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
name="unique_initiated_or_active_recording_per_room",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Recording {self.id} ({self.status})"
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
|
"""Compute and return abilities for a given user on the recording."""
|
||||||
|
|
||||||
|
roles = set(get_resource_roles(self, user))
|
||||||
|
|
||||||
|
is_owner_or_admin = bool(
|
||||||
|
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||||
|
)
|
||||||
|
|
||||||
|
is_final_status = RecordingStatusChoices.is_final(self.status)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"destroy": is_owner_or_admin and is_final_status,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": is_owner_or_admin,
|
||||||
|
"stop": is_owner_or_admin and not is_final_status,
|
||||||
|
"update": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_savable(self) -> bool:
|
||||||
|
"""Determine if the recording can be saved based on its current status."""
|
||||||
|
|
||||||
|
return self.status in {
|
||||||
|
RecordingStatusChoices.ACTIVE,
|
||||||
|
RecordingStatusChoices.STOPPED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingAccess(BaseAccess):
|
||||||
|
"""Relation model to give access to a recording for a user or a team with a role.
|
||||||
|
|
||||||
|
Recording Status Flow:
|
||||||
|
1. INITIATED: Initial state when recording is requested
|
||||||
|
2. ACTIVE: Recording is currently in progress
|
||||||
|
3. STOPPED: Recording has been stopped by user/system
|
||||||
|
4. SAVED: Recording has been successfully processed and stored
|
||||||
|
|
||||||
|
Error States:
|
||||||
|
- FAILED_TO_START: Worker failed to initialize recording
|
||||||
|
- FAILED_TO_STOP: Worker failed during stop operation
|
||||||
|
- ABORTED: Recording was terminated before completion
|
||||||
|
|
||||||
|
Warning: Worker failures may lead to database inconsistency between the actual
|
||||||
|
recording state and its status in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recording = models.ForeignKey(
|
||||||
|
Recording,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="accesses",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "meet_recording_access"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
verbose_name = _("Recording/user relation")
|
||||||
|
verbose_name_plural = _("Recording/user relations")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "recording"],
|
||||||
|
condition=models.Q(user__isnull=False), # Exclude null users
|
||||||
|
name="unique_recording_user",
|
||||||
|
violation_error_message=_("This user is already in this recording."),
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["team", "recording"],
|
||||||
|
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||||
|
name="unique_recording_team",
|
||||||
|
violation_error_message=_("This team is already in this recording."),
|
||||||
|
),
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=models.Q(user__isnull=False, team="")
|
||||||
|
| models.Q(user__isnull=True, team__gt=""),
|
||||||
|
name="check_recording_access_either_user_or_team",
|
||||||
|
violation_error_message=_("Either user or team must be set, not both."),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
|
"""
|
||||||
|
Compute and return abilities for a given user on the recording access.
|
||||||
|
"""
|
||||||
|
return self._get_abilities(self.recording, user)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Meet event parser classes, authentication and exceptions."""
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Authentication class for storage event token validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MachineUser:
|
||||||
|
"""Represent a non-interactive system user for automated storage operations."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pk = None
|
||||||
|
self.username = "storage_event_user"
|
||||||
|
self.is_active = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Indicate if this machine user is authenticated."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self) -> bool:
|
||||||
|
"""Indicate if this is an anonymous user."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_username(self) -> str:
|
||||||
|
"""Return the machine user identifier."""
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
|
class StorageEventAuthentication(BaseAuthentication):
|
||||||
|
"""Authenticate requests using a Bearer token for storage event integration.
|
||||||
|
This class validates Bearer tokens for storage events that don't map to database users.
|
||||||
|
It's designed for S3-compatible storage integrations and similar use cases.
|
||||||
|
Events are submitted when a webhook is configured on some bucket's events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AUTH_HEADER = "Authorization"
|
||||||
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
|
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
|
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||||
|
if not required_token:
|
||||||
|
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||||
|
raise AuthenticationFailed(
|
||||||
|
_("Authentication is enabled but token is not configured.")
|
||||||
|
)
|
||||||
|
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
logger.warning(
|
||||||
|
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed(_("Authorization header is required"))
|
||||||
|
|
||||||
|
auth_parts = auth_header.split(" ")
|
||||||
|
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||||
|
logger.warning(
|
||||||
|
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||||
|
|
||||||
|
token = auth_parts[1]
|
||||||
|
|
||||||
|
# Use constant-time comparison to prevent timing attacks
|
||||||
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
|
logger.warning(
|
||||||
|
"Authentication failed: Invalid token (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed(_("Invalid token"))
|
||||||
|
|
||||||
|
return MachineUser(), token
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""Return the WWW-Authenticate header value."""
|
||||||
|
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Storage parsers specific exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class ParsingEventDataError(Exception):
|
||||||
|
"""Raised when the request data is malformed, incomplete, or missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidBucketError(Exception):
|
||||||
|
"""Raised when the bucket name in the request does not match the expected one."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFileTypeError(Exception):
|
||||||
|
"""Raised when the file type in the request is not supported."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFilepathError(Exception):
|
||||||
|
"""Raised when the filepath in the request is invalid."""
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Meet storage event parser classes."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Dict, Optional, Protocol
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from .exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StorageEvent:
|
||||||
|
"""Represents a storage event with relevant metadata.
|
||||||
|
Attributes:
|
||||||
|
filepath: Identifier for the affected recording
|
||||||
|
filetype: Type of storage event
|
||||||
|
bucket_name: When the event occurred
|
||||||
|
metadata: Additional event data
|
||||||
|
"""
|
||||||
|
|
||||||
|
filepath: str
|
||||||
|
filetype: str
|
||||||
|
bucket_name: str
|
||||||
|
metadata: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.filepath is None:
|
||||||
|
raise TypeError("filepath cannot be None")
|
||||||
|
if self.filetype is None:
|
||||||
|
raise TypeError("filetype cannot be None")
|
||||||
|
if self.bucket_name is None:
|
||||||
|
raise TypeError("bucket_name cannot be None")
|
||||||
|
|
||||||
|
|
||||||
|
class EventParser(Protocol):
|
||||||
|
"""Interface for parsing storage events."""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name, allowed_filetypes=None):
|
||||||
|
"""Initialize parser with bucket name and optional allowed filetypes."""
|
||||||
|
|
||||||
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
|
"""Extract storage event data from raw dictionary input."""
|
||||||
|
|
||||||
|
def validate(self, data: StorageEvent) -> None:
|
||||||
|
"""Verify storage event data meets all requirements."""
|
||||||
|
|
||||||
|
def get_recording_id(self, data: Dict) -> str:
|
||||||
|
"""Extract recording ID from event dictionary."""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_parser() -> EventParser:
|
||||||
|
"""Return cached instance of configured event parser.
|
||||||
|
Uses function memoization instead of a factory class since the only
|
||||||
|
varying parameter is the parser class from settings. A factory class
|
||||||
|
would add unnecessary complexity when a cached function provides the
|
||||||
|
same singleton behavior with simpler code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||||
|
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
class MinioParser:
|
||||||
|
"""Handle parsing and validation of Minio storage events."""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||||
|
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||||
|
|
||||||
|
if not bucket_name:
|
||||||
|
raise ValueError("Bucket name cannot be None or empty")
|
||||||
|
|
||||||
|
self._bucket_name = bucket_name
|
||||||
|
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
|
||||||
|
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
self._filepath_regex = re.compile(
|
||||||
|
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(data):
|
||||||
|
"""Convert raw Minio event dictionary to StorageEvent object."""
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ParsingEventDataError("Received empty data.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record["s3"]
|
||||||
|
bucket_name = s3["bucket"]["name"]
|
||||||
|
file_object = s3["object"]
|
||||||
|
filepath = file_object["key"]
|
||||||
|
filetype = file_object["contentType"]
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
|
||||||
|
try:
|
||||||
|
return StorageEvent(
|
||||||
|
filepath=filepath,
|
||||||
|
filetype=filetype,
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
except TypeError as e:
|
||||||
|
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||||
|
|
||||||
|
def validate(self, event_data: StorageEvent) -> str:
|
||||||
|
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||||
|
|
||||||
|
if event_data.bucket_name != self._bucket_name:
|
||||||
|
raise InvalidBucketError(
|
||||||
|
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_data.filetype in self._allowed_filetypes:
|
||||||
|
raise InvalidFileTypeError(
|
||||||
|
f"Invalid file type, expected {self._allowed_filetypes},"
|
||||||
|
f"got '{event_data.filetype}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
match = self._filepath_regex.match(event_data.filepath)
|
||||||
|
if not match:
|
||||||
|
raise InvalidFilepathError(
|
||||||
|
f"Invalid filepath structure: {event_data.filepath}"
|
||||||
|
)
|
||||||
|
|
||||||
|
recording_id = match.group("recording_id")
|
||||||
|
return recording_id
|
||||||
|
|
||||||
|
def get_recording_id(self, data):
|
||||||
|
"""Extract recording ID from Minio event through parsing and validation."""
|
||||||
|
|
||||||
|
event_data = self.parse(data)
|
||||||
|
recording_id = self.validate(event_data)
|
||||||
|
|
||||||
|
return recording_id
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Meet worker services classes and exceptions."""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Recording and worker services specific exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerRequestError(Exception):
|
||||||
|
"""Raised when there is an issue with the worker request"""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerConnectionError(Exception):
|
||||||
|
"""Raised when there is an issue connecting to the worker."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerResponseError(Exception):
|
||||||
|
"""Raised when the worker's response is not as expected."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingStartError(Exception):
|
||||||
|
"""Raised when there is an error starting the recording."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingStopError(Exception):
|
||||||
|
"""Raised when there is an error stopping the recording."""
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Factory, configurations and Protocol to create worker services"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerServiceConfig:
|
||||||
|
"""Declare Worker Service common configurations"""
|
||||||
|
|
||||||
|
output_folder: str
|
||||||
|
server_configurations: Dict[str, Any]
|
||||||
|
verify_ssl: Optional[bool]
|
||||||
|
bucket_args: Optional[dict]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache
|
||||||
|
def from_settings(cls) -> "WorkerServiceConfig":
|
||||||
|
"""Load configuration from Django settings with caching for efficiency."""
|
||||||
|
|
||||||
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
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,
|
||||||
|
"secret": settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
"region": settings.AWS_S3_REGION_NAME,
|
||||||
|
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||||
|
"force_path_style": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerService(Protocol):
|
||||||
|
"""Define the interface for interacting with a worker service."""
|
||||||
|
|
||||||
|
hrid: ClassVar[str]
|
||||||
|
|
||||||
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
|
def start(self, room_id: str, recording_id: str) -> str:
|
||||||
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
|
def stop(self, worker_id: str) -> str:
|
||||||
|
"""Stop recording for a specified worker."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_service(mode: str) -> WorkerService:
|
||||||
|
"""Instantiate a worker service by its mode."""
|
||||||
|
|
||||||
|
worker_registry: Dict[str, str] = settings.RECORDING_WORKER_CLASSES
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker_class_path = worker_registry[mode]
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Recording mode '{mode}' not found in RECORDING_WORKER_CLASSES. "
|
||||||
|
f"Available modes: {list(worker_registry.keys())}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
worker_class: Type[WorkerService] = import_string(worker_class_path)
|
||||||
|
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
return worker_class(config=config)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Mediator between the worker service and recording instances in the Django ORM."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
|
||||||
|
from .exceptions import (
|
||||||
|
RecordingStartError,
|
||||||
|
RecordingStopError,
|
||||||
|
WorkerConnectionError,
|
||||||
|
WorkerRequestError,
|
||||||
|
WorkerResponseError,
|
||||||
|
)
|
||||||
|
from .factories import WorkerService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerServiceMediator:
|
||||||
|
"""Mediate interactions between a worker service and a recording instance.
|
||||||
|
|
||||||
|
A mediator class that decouples the worker from Django ORM, handles recording updates
|
||||||
|
based on worker status, and transforms worker errors into user-friendly exceptions.
|
||||||
|
Implements Mediator pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker_service: WorkerService):
|
||||||
|
"""Initialize the WorkerServiceMediator with the provided worker service."""
|
||||||
|
|
||||||
|
self._worker_service = worker_service
|
||||||
|
|
||||||
|
def start(self, recording: Recording):
|
||||||
|
"""Start the recording process using the worker service.
|
||||||
|
|
||||||
|
If the operation is successful, the recording's status will
|
||||||
|
transition from INITIATED to ACTIVE, else to FAILED_TO_START to keep track of errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recording (Recording): The recording instance to start.
|
||||||
|
Raises:
|
||||||
|
RecordingStartError: If there is an error starting the recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if recording.status != RecordingStatusChoices.INITIATED:
|
||||||
|
logger.error("Cannot start recording in %s status.", recording.status)
|
||||||
|
raise RecordingStartError()
|
||||||
|
|
||||||
|
room_name = str(recording.room.id)
|
||||||
|
try:
|
||||||
|
worker_id = self._worker_service.start(room_name, recording.id)
|
||||||
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
|
)
|
||||||
|
recording.status = RecordingStatusChoices.FAILED_TO_START
|
||||||
|
raise RecordingStartError() from e
|
||||||
|
else:
|
||||||
|
recording.worker_id = worker_id
|
||||||
|
recording.status = RecordingStatusChoices.ACTIVE
|
||||||
|
finally:
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Worker started for room %s (worker ID: %s)",
|
||||||
|
recording.room,
|
||||||
|
recording.worker_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self, recording: Recording):
|
||||||
|
"""Stop the recording process using the worker service.
|
||||||
|
|
||||||
|
If the operation is successful, the recording's status will transition
|
||||||
|
from ACTIVE to STOPPED, else to FAILED_TO_STOP to keep track of errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recording (Recording): The recording instance to stop.
|
||||||
|
Raises:
|
||||||
|
RecordingStopError: If there is an error stopping the recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if recording.status != RecordingStatusChoices.ACTIVE:
|
||||||
|
logger.error("Cannot stop recording in %s status.", recording.status)
|
||||||
|
raise RecordingStopError()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._worker_service.stop(worker_id=recording.worker_id)
|
||||||
|
except (WorkerConnectionError, WorkerResponseError) as e:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to stop recording for room %s: %s", recording.room.slug, e
|
||||||
|
)
|
||||||
|
recording.status = RecordingStatusChoices.FAILED_TO_STOP
|
||||||
|
raise RecordingStopError() from e
|
||||||
|
else:
|
||||||
|
recording.status = RecordingStatusChoices[response]
|
||||||
|
finally:
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
logger.info("Worker stopped for room %s", recording.room)
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Worker services in charge of recording a room."""
|
||||||
|
|
||||||
|
# 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 .exceptions import WorkerConnectionError, WorkerResponseError
|
||||||
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEgressService:
|
||||||
|
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||||
|
|
||||||
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
|
self._config = config
|
||||||
|
self._s3 = livekit_api.S3Upload(**config.bucket_args)
|
||||||
|
|
||||||
|
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||||
|
"""Construct the file path for a given filename and extension.
|
||||||
|
Unsecure method, doesn't handle paths robustly and securely.
|
||||||
|
"""
|
||||||
|
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def stop(self, worker_id: str) -> str:
|
||||||
|
"""Stop an ongoing egress worker.
|
||||||
|
The StopEgressRequest is shared among all types of egress,
|
||||||
|
so a single implementation in the base class should be sufficient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
request = livekit_api.StopEgressRequest(
|
||||||
|
egress_id=worker_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._handle_request(request, "stop_egress")
|
||||||
|
|
||||||
|
if not response.status:
|
||||||
|
raise WorkerResponseError(
|
||||||
|
"LiveKit response is missing the recording status."
|
||||||
|
)
|
||||||
|
|
||||||
|
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||||
|
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||||
|
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||||
|
return "ABORTED"
|
||||||
|
|
||||||
|
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||||
|
return "STOPPED"
|
||||||
|
|
||||||
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
|
def start(self, room_name, recording_id):
|
||||||
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Subclass must implement this method.")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
|
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||||
|
|
||||||
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
|
def start(self, room_name, recording_id):
|
||||||
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
|
# Save room's recording as a mp4 video file.
|
||||||
|
file_type = livekit_api.EncodedFileType.MP4
|
||||||
|
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
||||||
|
|
||||||
|
file_output = livekit_api.EncodedFileOutput(
|
||||||
|
file_type=file_type,
|
||||||
|
filepath=filepath,
|
||||||
|
s3=self._s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = livekit_api.RoomCompositeEgressRequest(
|
||||||
|
room_name=room_name,
|
||||||
|
file_outputs=[file_output],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._handle_request(request, "start_room_composite_egress")
|
||||||
|
|
||||||
|
if not response.egress_id:
|
||||||
|
raise WorkerResponseError("Egress ID not found in the response.")
|
||||||
|
|
||||||
|
return response.egress_id
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCompositeEgressService(BaseEgressService):
|
||||||
|
"""Record multiple participant audio tracks into a single output '.ogg' file."""
|
||||||
|
|
||||||
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
|
def start(self, room_name, recording_id):
|
||||||
|
"""Start the audio composite egress process for a recording."""
|
||||||
|
|
||||||
|
# Save room's recording as an ogg audio file.
|
||||||
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
||||||
|
|
||||||
|
file_output = livekit_api.EncodedFileOutput(
|
||||||
|
file_type=file_type,
|
||||||
|
filepath=filepath,
|
||||||
|
s3=self._s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = livekit_api.RoomCompositeEgressRequest(
|
||||||
|
room_name=room_name, file_outputs=[file_output], audio_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self._handle_request(request, "start_room_composite_egress")
|
||||||
|
|
||||||
|
if not response.egress_id:
|
||||||
|
raise WorkerResponseError("Egress ID not found in the response.")
|
||||||
|
|
||||||
|
return response.egress_id
|
||||||
@@ -11,32 +11,35 @@ from core.factories import UserFactory
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_existing_user_no_email(
|
def test_authentication_getter_existing_user(monkeypatch):
|
||||||
django_assert_num_queries, monkeypatch
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
If an existing user matches the user's info sub, the user should be returned.
|
If an existing user matches, the user should be returned.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
db_user = UserFactory()
|
db_user = UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {"sub": db_user.sub}
|
return {"sub": db_user.sub, "email": "some@mail.com"}
|
||||||
|
|
||||||
|
def get_existing_user(*args):
|
||||||
|
return db_user
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
|
||||||
|
)
|
||||||
|
|
||||||
with django_assert_num_queries(1):
|
user = klass.get_or_create_user(
|
||||||
user = klass.get_or_create_user(
|
access_token="test-token", id_token=None, payload=None
|
||||||
access_token="test-token", id_token=None, payload=None
|
)
|
||||||
)
|
|
||||||
|
|
||||||
assert user == db_user
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches the user's info sub, a user should be created.
|
If no user matches, a user should be created.
|
||||||
User's info doesn't contain an email, created user's email should be empty.
|
User's info doesn't contain an email, created user's email should be empty.
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
@@ -58,16 +61,15 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
|
|
||||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches the user's info sub, a user should be created.
|
If no user matches, a user should be created.
|
||||||
User's email and name should be set on the identity.
|
User's info contains an email, created user's email should be filled.
|
||||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
email = "meet@example.com"
|
email = "meet@example.com"
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
return {"sub": "123", "email": email}
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
@@ -77,6 +79,34 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
|||||||
|
|
||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
|
assert user.full_name is None
|
||||||
|
assert user.short_name is None
|
||||||
|
assert user.password == "!"
|
||||||
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
|
||||||
|
def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||||
|
"""
|
||||||
|
If no user matches, a user should be created.
|
||||||
|
User's info contains name-related field, created user's full and short names should be filled,
|
||||||
|
whether the email is filled
|
||||||
|
"""
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.sub == "123"
|
||||||
|
assert user.email == email
|
||||||
|
assert user.full_name == "John Doe"
|
||||||
|
assert user.short_name == "John"
|
||||||
assert user.password == "!"
|
assert user.password == "!"
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
@@ -102,3 +132,283 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
|||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
assert models.User.objects.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_oidc_user_getter_empty_sub(django_assert_num_queries, monkeypatch):
|
||||||
|
"""The user's info contains a sub, but it's an empty string."""
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"test": "123", "sub": ""}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
with (
|
||||||
|
django_assert_num_queries(0),
|
||||||
|
pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match="User info contained no recognizable user identification",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
|
assert models.User.objects.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_get_inactive_user(monkeypatch):
|
||||||
|
"""Test an exception is raised when attempting to authenticate inactive user."""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": db_user.sub}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
with (
|
||||||
|
pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match="User account is disabled",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_by_sub(django_assert_num_queries):
|
||||||
|
"""Should return user when found by sub, and email is matching."""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
user = klass.get_existing_user(db_user.sub, db_user.email)
|
||||||
|
|
||||||
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
||||||
|
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
||||||
|
"""Should not return a user when not found by sub, and email is empty."""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
empty_email = ""
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
user = klass.get_existing_user("wrong-sub", empty_email)
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_by_email(django_assert_num_queries, settings):
|
||||||
|
"""Should return user when found by email, and sub is not matching."""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||||
|
|
||||||
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||||
|
"""Should match email case-insensitively when falling back to email."""
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
user = klass.get_existing_user("wrong-sub", "FOO@MAIL.COM")
|
||||||
|
|
||||||
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
||||||
|
"""Should handle multiple users with same email appropriately."""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
email = "foo@mail.com"
|
||||||
|
UserFactory(email=email)
|
||||||
|
UserFactory(email=email) # Second user with same email
|
||||||
|
|
||||||
|
with (
|
||||||
|
django_assert_num_queries(2),
|
||||||
|
pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match="Multiple user accounts share a common email.",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
klass.get_existing_user("wrong-sub", email)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||||
|
"""Should not match emails with whitespace."""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
UserFactory(email="foo@mail.com")
|
||||||
|
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"email",
|
||||||
|
[
|
||||||
|
"john.doe@example.com", # Fullwidth character in domain
|
||||||
|
"john.doe@еxample.com", # Cyrillic 'е' in domain
|
||||||
|
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
|
||||||
|
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
|
||||||
|
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
|
||||||
|
"""Test email matching security against visually similar but non-ASCII domains.
|
||||||
|
|
||||||
|
Validates that emails with Unicode characters that visually resemble ASCII
|
||||||
|
(homoglyphs) are treated as distinct from their ASCII counterparts for security,
|
||||||
|
per RFC compliance requirements for hostnames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email="john.doe@example.com")
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user != db_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"given_name, usual_name, email",
|
||||||
|
[
|
||||||
|
("Jack", "Doe", "john.doe@example.com"),
|
||||||
|
("John", "Duy", "john.doe@example.com"),
|
||||||
|
("John", "Doe", "jack.duy@example.com"),
|
||||||
|
("Jack", "Duy", "jack.duy@example.com"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_authentication_getter_existing_user_change_fields(
|
||||||
|
given_name, usual_name, email, django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""It should update the email or name fields on the user when they change."""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
user = UserFactory(
|
||||||
|
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {
|
||||||
|
"sub": user.sub,
|
||||||
|
"email": email,
|
||||||
|
"given_name": given_name,
|
||||||
|
"usual_name": usual_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
# One and only one additional update query when a field has changed
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
authenticated_user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user == authenticated_user
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.email == email
|
||||||
|
assert user.full_name == f"{given_name:s} {usual_name:s}"
|
||||||
|
assert user.short_name == given_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_info, expected_name",
|
||||||
|
[
|
||||||
|
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
|
||||||
|
(
|
||||||
|
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
|
||||||
|
"John M Doe",
|
||||||
|
),
|
||||||
|
({"family_name": "Doe"}, "Doe"),
|
||||||
|
({"given_name": "", "family_name": ""}, None),
|
||||||
|
({}, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_compute_full_name(user_info, expected_name, settings):
|
||||||
|
"""Test full name computation from OIDC user info fields."""
|
||||||
|
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
|
||||||
|
"given_name",
|
||||||
|
"middle_name",
|
||||||
|
"family_name",
|
||||||
|
]
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
assert klass.compute_full_name(user_info) == expected_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_full_name_no_fields(settings):
|
||||||
|
"""Test full name computation with empty field configuration."""
|
||||||
|
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
assert klass.compute_full_name({"given_name": "John"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"claims",
|
||||||
|
[
|
||||||
|
{"email": "john.doe@example.com"}, # Same data - no change needed
|
||||||
|
{"email": ""}, # Empty strings should not override
|
||||||
|
{"non_related_field": "foo"}, # Unrelated fields should be ignored
|
||||||
|
{}, # Empty claims should not affect user
|
||||||
|
{"email": None}, # None values should be ignored
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
||||||
|
"""Test that user attributes remain unchanged when claims don't require updates."""
|
||||||
|
|
||||||
|
user = UserFactory(
|
||||||
|
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with django_assert_num_queries(0):
|
||||||
|
klass.update_user_if_needed(user, claims)
|
||||||
|
|
||||||
|
user.refresh_from_db()
|
||||||
|
|
||||||
|
assert user.email == "john.doe@example.com"
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Test event authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=E1128
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
from core.recording.event.authentication import (
|
||||||
|
MachineUser,
|
||||||
|
StorageEventAuthentication,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_authentication(settings):
|
||||||
|
"""Test successful authentication with valid token."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer valid-test-token"}
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token == "valid-test-token"
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_authentication_with_header(settings):
|
||||||
|
"""Authentication should pass when no auth is configured, and header is present."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer some-token"}
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_authentication_without_header(settings):
|
||||||
|
"""Authentication should pass when no auth is configured, and no header is present."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_when_disabled(settings):
|
||||||
|
"""Authentication should pass when disabled, regardless of token configuration."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_fails_when_token_not_configured(settings):
|
||||||
|
"""Authentication should fail when authentication is enabled but no token is configured."""
|
||||||
|
|
||||||
|
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
AuthenticationFailed,
|
||||||
|
match="Authentication is enabled but token is not configured",
|
||||||
|
):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_auth_header(settings):
|
||||||
|
"""Test failure when Authorization header is missing."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_auth_header_format(settings):
|
||||||
|
"""Test failure when Authorization header has invalid format."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "InvalidFormat"}
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token_type(settings):
|
||||||
|
"""Test failure when token type is not Bearer."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Basic some-token"}
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token(settings):
|
||||||
|
"""Test failure when token is invalid."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer wrong-token"}
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_auth_header(settings):
|
||||||
|
"""Test failure when Authorization header is malformed."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer"} # Missing token part
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_header():
|
||||||
|
"""Test the WWW-Authenticate header value."""
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
|
"""Test failure when Authorization header contains multiple spaces."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
|
StorageEventAuthentication().authenticate(request)
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""
|
||||||
|
Test event parsers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212,W0621,W0613
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
from core.recording.event.parsers import (
|
||||||
|
MinioParser,
|
||||||
|
StorageEvent,
|
||||||
|
get_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_minio_event():
|
||||||
|
"""Mock a valid Minio event."""
|
||||||
|
return {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"contentType": "audio/ogg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minio_parser():
|
||||||
|
"""Mock a Minio parser."""
|
||||||
|
return MinioParser(bucket_name="test-bucket")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||||
|
"""Test parsing a valid Minio event."""
|
||||||
|
event = minio_parser.parse(valid_minio_event)
|
||||||
|
assert isinstance(event, StorageEvent)
|
||||||
|
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||||
|
assert event.filetype == "audio/ogg"
|
||||||
|
assert event.bucket_name == "test-bucket"
|
||||||
|
assert event.metadata is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_empty_data(minio_parser):
|
||||||
|
"""Test parsing empty event data raises error."""
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||||
|
minio_parser.parse({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_missing_keys(minio_parser):
|
||||||
|
"""Test parsing event with missing key."""
|
||||||
|
|
||||||
|
invalid_minio_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": None},
|
||||||
|
# Missing 'object' key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
|
||||||
|
minio_parser.parse(invalid_minio_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_none_key(minio_parser):
|
||||||
|
"""Test parsing event with None field."""
|
||||||
|
|
||||||
|
invalid_minio_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"contentType": None, # 'contentType' should not be None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
|
||||||
|
minio_parser.parse(invalid_minio_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_invalid_bucket(minio_parser):
|
||||||
|
"""Test validation with wrong bucket name."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="wrong-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidBucketError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_invalid_filetype(minio_parser):
|
||||||
|
"""Test validation with unsupported file type."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||||
|
filetype="text/plain", # Not included in the default allowed filetypes
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidFileTypeError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid_filepath",
|
||||||
|
[
|
||||||
|
"invalid_filepath",
|
||||||
|
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||||
|
"""Test validation with malformed filepath."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath=invalid_filepath,
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidFilepathError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_valid_event(minio_parser):
|
||||||
|
"""Test validation with valid event data."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
recording_id = minio_parser.validate(event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_recording_id_success(minio_parser, valid_minio_event):
|
||||||
|
"""Test successful extraction of recording ID."""
|
||||||
|
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_filepath_with_folder(minio_parser):
|
||||||
|
"""Test validation of filepath with folder structure."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
recording_id = minio_parser.validate(event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_with_video_type(minio_parser):
|
||||||
|
"""Test parsing event with video file type."""
|
||||||
|
video_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||||
|
"contentType": "video/mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
event = minio_parser.parse(video_event)
|
||||||
|
assert event.filetype == "video/mp4"
|
||||||
|
assert event.filepath.endswith(".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_allowed_filetypes():
|
||||||
|
"""Test MinioParser with empty allowed_filetypes."""
|
||||||
|
empty_types = set()
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||||
|
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_allowed_filetypes():
|
||||||
|
"""Test MinioParser with empty allowed_filetypes."""
|
||||||
|
custom_types = {"audio/mp3", "video/mov"}
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||||
|
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_custom_filetypes():
|
||||||
|
"""Test validation of filepath with folder structure."""
|
||||||
|
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||||
|
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/mp3",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_none_bucket():
|
||||||
|
"""Test MinioParser constructor with None bucket name."""
|
||||||
|
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||||
|
MinioParser(bucket_name=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_empty_bucket():
|
||||||
|
"""Test MinioParser constructor with empty bucket name."""
|
||||||
|
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||||
|
MinioParser(bucket_name="")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clear_lru_cache():
|
||||||
|
"""Fixture to clear the LRU cache between tests."""
|
||||||
|
get_parser.cache_clear()
|
||||||
|
yield
|
||||||
|
get_parser.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_correct_instance(clear_lru_cache):
|
||||||
|
"""Test if get_parser returns the correct parser instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||||
|
parser = get_parser()
|
||||||
|
assert isinstance(parser, MinioParser)
|
||||||
|
assert parser._bucket_name == "test-bucket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_caching_behavior(clear_lru_cache):
|
||||||
|
"""Test if the function properly caches the parser instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||||
|
parser1 = get_parser()
|
||||||
|
parser2 = get_parser()
|
||||||
|
assert parser1 is parser2 # Check object identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_settings_new_instance():
|
||||||
|
"""Test if changing settings creates a new instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
|
||||||
|
parser = get_parser()
|
||||||
|
assert parser._bucket_name == "different-bucket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_error_handling(clear_lru_cache):
|
||||||
|
"""Test handling of import errors for invalid parser class."""
|
||||||
|
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
get_parser()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.parsers.import_string")
|
||||||
|
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
||||||
|
"""Test that parser class is instantiated only once due to caching."""
|
||||||
|
mock_parser_cls = type(
|
||||||
|
"MockParser",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"__init__": lambda self, bucket_name: setattr(
|
||||||
|
self, "_bucket_name", bucket_name
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_import_string.return_value = mock_parser_cls
|
||||||
|
|
||||||
|
# First call
|
||||||
|
parser1 = get_parser()
|
||||||
|
# Second call
|
||||||
|
parser2 = get_parser()
|
||||||
|
|
||||||
|
# Verify import_string was called only once
|
||||||
|
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||||
|
assert parser1 is parser2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_behavior(clear_lru_cache, settings):
|
||||||
|
"""Test that cache clearing creates new instance."""
|
||||||
|
|
||||||
|
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
|
||||||
|
|
||||||
|
parser1 = get_parser()
|
||||||
|
get_parser.cache_clear()
|
||||||
|
parser2 = get_parser()
|
||||||
|
|
||||||
|
assert parser1 is not parser2 # Should be different instances after cache clear
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints in the Meet core app: delete.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||||
|
from ...models import Recording
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_delete_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to destroy a recording."""
|
||||||
|
recording = RecordingFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_delete_authenticated():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be allowed to delete a recording
|
||||||
|
from which they are not related.
|
||||||
|
"""
|
||||||
|
recording = RecordingFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_delete_members():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be allowed to delete a recording
|
||||||
|
from which they are only a member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
access = UserRecordingAccessFactory(role="member", user=user)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
["owner", "administrator"],
|
||||||
|
)
|
||||||
|
def test_api_recordings_delete_active(role):
|
||||||
|
"""
|
||||||
|
Authenticated users cannot delete active recordings, even with deletion privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
["owner", "administrator"],
|
||||||
|
)
|
||||||
|
def test_api_recordings_delete_final(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be allowed to delete an active recording
|
||||||
|
from which they are an admin or owner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="saved")
|
||||||
|
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints in the Meet core app: list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import operator
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_list_anonymous():
|
||||||
|
"""Anonymous users should not be able to list recordings."""
|
||||||
|
factories.RecordingFactory()
|
||||||
|
response = APIClient().get("/api/v1.0/recordings/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
["administrator", "member", "owner"],
|
||||||
|
)
|
||||||
|
def test_api_recordings_list_authenticated_direct(role):
|
||||||
|
"""
|
||||||
|
Authenticated users listing recordings, should only see the recordings
|
||||||
|
to which they are related.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
|
||||||
|
access = factories.UserRecordingAccessFactory(role=role, user=user)
|
||||||
|
factories.UserRecordingAccessFactory(user=other_user)
|
||||||
|
|
||||||
|
recording = access.recording
|
||||||
|
room = recording.room
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/recordings/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
expected_ids = {
|
||||||
|
str(recording.id),
|
||||||
|
}
|
||||||
|
result_ids = {result["id"] for result in results}
|
||||||
|
assert expected_ids == result_ids
|
||||||
|
assert results[0] == {
|
||||||
|
"id": str(recording.id),
|
||||||
|
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"room": {
|
||||||
|
"id": str(room.id),
|
||||||
|
"is_public": room.is_public,
|
||||||
|
"name": room.name,
|
||||||
|
"slug": room.slug,
|
||||||
|
},
|
||||||
|
"status": "initiated",
|
||||||
|
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to list recordings they are a
|
||||||
|
owner/administrator/member of via a team.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
|
||||||
|
|
||||||
|
recordings_team1 = [
|
||||||
|
access.recording
|
||||||
|
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
|
||||||
|
]
|
||||||
|
recordings_team2 = [
|
||||||
|
access.recording
|
||||||
|
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
|
||||||
|
]
|
||||||
|
|
||||||
|
expected_ids = {
|
||||||
|
str(recording.id) for recording in recordings_team1 + recordings_team2
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/recordings/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 5
|
||||||
|
results_id = {result["id"] for result in results}
|
||||||
|
assert expected_ids == results_id
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||||
|
def test_api_recordings_list_pagination(_mock_page_size):
|
||||||
|
"""Pagination should work as expected."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
recording_ids = [
|
||||||
|
str(access.recording_id)
|
||||||
|
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/recordings/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["count"] == 3
|
||||||
|
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
|
||||||
|
assert content["previous"] is None
|
||||||
|
|
||||||
|
assert len(content["results"]) == 2
|
||||||
|
for item in content["results"]:
|
||||||
|
recording_ids.remove(item["id"])
|
||||||
|
|
||||||
|
# Get page 2
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/recordings/?page=2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
assert content["count"] == 3
|
||||||
|
assert content["next"] is None
|
||||||
|
assert content["previous"], "http://testserver/api/v1.0/recordings/"
|
||||||
|
|
||||||
|
assert len(content["results"]) == 1
|
||||||
|
recording_ids.remove(content["results"][0]["id"])
|
||||||
|
assert recording_ids == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_list_authenticated_distinct():
|
||||||
|
"""A recording for a room with several related users should only be listed once."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(users=[user, other_user])
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/recordings/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content["results"]) == 1
|
||||||
|
assert content["results"][0]["id"] == str(recording.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recordings_list_ordering_default():
|
||||||
|
"""Recordings should be ordered by descending "updated_at" by default"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.RecordingFactory.create_batch(5, users=[user])
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/recordings/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 5
|
||||||
|
|
||||||
|
# Check that results are sorted by descending "updated_at" as expected
|
||||||
|
for i in range(4):
|
||||||
|
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints in the Meet core app: save recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621,W0613
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory
|
||||||
|
from ...models import Recording, RecordingStatusChoices
|
||||||
|
from ...recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording_settings(settings):
|
||||||
|
"""Configure recording-related and storage event Django settings."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_parser():
|
||||||
|
"""Mock 'get_parser' factory function."""
|
||||||
|
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
||||||
|
yield mock_parser
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_anonymous(settings, client):
|
||||||
|
"""Anonymous users should not be allowed to save room recordings."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
RecordingFactory(status="active")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_wrong_bearer(settings, client):
|
||||||
|
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_permission_needed(settings, client):
|
||||||
|
"""Recordings should not be saved when feature is disabled."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of parsing errors in recording event data."""
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Invalid request data: Error message"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of invalid storage bucket errors in recording event data."""
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Invalid bucket specified"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||||
|
"""Test handling of unsupported file types in recording event data."""
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
||||||
|
"unsupported '.json'"
|
||||||
|
)
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of events for non-existent recordings."""
|
||||||
|
|
||||||
|
RecordingFactory(status="active")
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "No recording found for this event."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
|
)
|
||||||
|
def test_save_recording_non_savable_recording(
|
||||||
|
recording_settings, mock_get_parser, client, status
|
||||||
|
):
|
||||||
|
"""Test that recordings in non-savable states cannot be saved."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
||||||
|
" in an error state or has already been saved."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||||
|
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
||||||
|
"""Test successful saving of recordings in valid states."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Test worker service factories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212,W0621,W0613
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.recording.worker.factories import (
|
||||||
|
WorkerService,
|
||||||
|
WorkerServiceConfig,
|
||||||
|
get_worker_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_lru_cache():
|
||||||
|
"""Clear the lru_cache before and after each test"""
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_settings():
|
||||||
|
"""Fixture to provide test Django 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",
|
||||||
|
"AWS_S3_REGION_NAME": "test-region",
|
||||||
|
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use override_settings to properly patch Django settings
|
||||||
|
with override_settings(**mocked_settings):
|
||||||
|
yield test_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_config(test_settings):
|
||||||
|
"""Fixture to provide a WorkerServiceConfig instance"""
|
||||||
|
return WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
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",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_immutability(default_config):
|
||||||
|
"""Test that config instances are immutable after creation"""
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
default_config.output_folder = "new/path"
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
AWS_S3_REGION_NAME="test-region",
|
||||||
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
|
)
|
||||||
|
def test_config_caching():
|
||||||
|
"""Test that from_settings method caches its result"""
|
||||||
|
# Clear cache before testing caching behavior
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
|
||||||
|
config1 = WorkerServiceConfig.from_settings()
|
||||||
|
config2 = WorkerServiceConfig.from_settings()
|
||||||
|
assert config1 is config2
|
||||||
|
|
||||||
|
|
||||||
|
class MockWorkerService(WorkerService):
|
||||||
|
"""Mock worker service for testing."""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_import_string(monkeypatch):
|
||||||
|
"""Fixture to mock import_string function."""
|
||||||
|
mock = Mock(return_value=MockWorkerService)
|
||||||
|
monkeypatch.setattr("core.recording.worker.factories.import_string", mock)
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_valid_mode(mock_import_string, settings, default_config):
|
||||||
|
"""Test getting worker service with valid mode."""
|
||||||
|
|
||||||
|
settings.RECORDING_WORKER_CLASSES = {
|
||||||
|
"test_mode": "path.to.MockWorkerService",
|
||||||
|
"another_mode": "path.to.AnotherWorkerService",
|
||||||
|
}
|
||||||
|
|
||||||
|
worker = get_worker_service("test_mode")
|
||||||
|
|
||||||
|
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
||||||
|
assert isinstance(worker, MockWorkerService)
|
||||||
|
assert worker.config == default_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_invalid_mode(settings, mock_import_string, default_config):
|
||||||
|
"""Test getting worker service with invalid mode raises ValueError."""
|
||||||
|
|
||||||
|
settings.RECORDING_WORKER_CLASSES = {
|
||||||
|
"test_mode": "path.to.MockWorkerService",
|
||||||
|
"another_mode": "path.to.AnotherWorkerService",
|
||||||
|
}
|
||||||
|
|
||||||
|
worker = get_worker_service("test_mode")
|
||||||
|
|
||||||
|
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
||||||
|
assert isinstance(worker, MockWorkerService)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
get_worker_service("invalid_mode")
|
||||||
|
mock_import_string.assert_not_called()
|
||||||
|
|
||||||
|
assert "Recording mode 'invalid_mode' not found" in str(exc_info.value)
|
||||||
|
assert "Available modes: ['test_mode', 'another_mode']" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_import_error(mock_import_string, settings):
|
||||||
|
"""Test handling of import errors."""
|
||||||
|
|
||||||
|
mock_import_string.side_effect = ImportError("Module not found")
|
||||||
|
|
||||||
|
settings.RECORDING_WORKER_CLASSES = {
|
||||||
|
"test_mode": "path.to.MockWorkerService",
|
||||||
|
"another_mode": "path.to.AnotherWorkerService",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ImportError) as exc_info:
|
||||||
|
get_worker_service("test_mode")
|
||||||
|
|
||||||
|
assert "Module not found" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_empty_registry(settings):
|
||||||
|
"""Test behavior when worker registry is empty."""
|
||||||
|
|
||||||
|
settings.RECORDING_WORKER_CLASSES = {}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
get_worker_service("any_mode")
|
||||||
|
|
||||||
|
assert "Available modes: []" in str(exc_info.value)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Test WorkerServiceMediator class."""
|
||||||
|
|
||||||
|
# pylint: disable=W0621,W0613
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import RecordingFactory
|
||||||
|
from core.models import RecordingStatusChoices
|
||||||
|
from core.recording.worker.exceptions import (
|
||||||
|
RecordingStartError,
|
||||||
|
RecordingStopError,
|
||||||
|
WorkerConnectionError,
|
||||||
|
WorkerRequestError,
|
||||||
|
WorkerResponseError,
|
||||||
|
)
|
||||||
|
from core.recording.worker.factories import WorkerService
|
||||||
|
from core.recording.worker.mediator import WorkerServiceMediator
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_service():
|
||||||
|
"""Fixture for mock worker service"""
|
||||||
|
return Mock(spec=WorkerService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mediator(mock_worker_service):
|
||||||
|
"""Fixture for WorkerServiceMediator"""
|
||||||
|
return WorkerServiceMediator(mock_worker_service)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_success(mediator, mock_worker_service):
|
||||||
|
"""Test successful recording start"""
|
||||||
|
# Setup
|
||||||
|
worker_id = "test-worker-123"
|
||||||
|
mock_worker_service.start.return_value = worker_id
|
||||||
|
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED, worker_id=None
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
# Verify worker service call
|
||||||
|
expected_room_name = str(mock_recording.room.id)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
expected_room_name, mock_recording.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify recording updates
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.worker_id == worker_id
|
||||||
|
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
|
)
|
||||||
|
def test_mediator_start_recording_worker_errors(
|
||||||
|
mediator, mock_worker_service, error_class
|
||||||
|
):
|
||||||
|
"""Test handling of various worker errors during start"""
|
||||||
|
# Setup
|
||||||
|
mock_worker_service.start.side_effect = error_class("Test error")
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED, worker_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute and verify
|
||||||
|
with pytest.raises(RecordingStartError):
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
# Verify recording updates
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||||
|
assert mock_recording.worker_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status",
|
||||||
|
[
|
||||||
|
RecordingStatusChoices.ACTIVE,
|
||||||
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
|
RecordingStatusChoices.STOPPED,
|
||||||
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.ABORTED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_mediator_start_recording_from_forbidden_status(
|
||||||
|
mediator, mock_worker_service, status
|
||||||
|
):
|
||||||
|
"""Test handling of various worker errors during start"""
|
||||||
|
# Setup
|
||||||
|
mock_recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
# Execute and verify
|
||||||
|
with pytest.raises(RecordingStartError):
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
# Verify recording was not updated
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.status == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||||
|
"""Test successful recording stop"""
|
||||||
|
# Setup
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||||
|
)
|
||||||
|
mock_worker_service.stop.return_value = "STOPPED"
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
mediator.stop(mock_recording)
|
||||||
|
|
||||||
|
# Verify worker service call
|
||||||
|
mock_worker_service.stop.assert_called_once_with(worker_id=mock_recording.worker_id)
|
||||||
|
|
||||||
|
# Verify recording updates
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.status == RecordingStatusChoices.STOPPED
|
||||||
|
|
||||||
|
|
||||||
|
def test_mediator_stop_recording_aborted(mediator, mock_worker_service):
|
||||||
|
"""Test recording stop when worker returns ABORTED"""
|
||||||
|
# Setup
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||||
|
)
|
||||||
|
mock_worker_service.stop.return_value = "ABORTED"
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
mediator.stop(mock_recording)
|
||||||
|
|
||||||
|
# Verify recording updates
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.status == RecordingStatusChoices.ABORTED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("error_class", [WorkerConnectionError, WorkerResponseError])
|
||||||
|
def test_mediator_stop_recording_worker_errors(
|
||||||
|
mediator, mock_worker_service, error_class
|
||||||
|
):
|
||||||
|
"""Test handling of worker errors during stop"""
|
||||||
|
# Setup
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||||
|
)
|
||||||
|
mock_worker_service.stop.side_effect = error_class("Test error")
|
||||||
|
|
||||||
|
# Execute and verify
|
||||||
|
with pytest.raises(RecordingStopError):
|
||||||
|
mediator.stop(mock_recording)
|
||||||
|
|
||||||
|
# Verify recording updates
|
||||||
|
mock_recording.refresh_from_db()
|
||||||
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_STOP
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""
|
||||||
|
Test worker service classes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212,W0621,W0613,E1101
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||||
|
from core.recording.worker.factories import WorkerServiceConfig
|
||||||
|
from core.recording.worker.services import (
|
||||||
|
AudioCompositeEgressService,
|
||||||
|
BaseEgressService,
|
||||||
|
VideoCompositeEgressService,
|
||||||
|
livekit_api,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config():
|
||||||
|
"""Fixture to provide a WorkerServiceConfig instance"""
|
||||||
|
return WorkerServiceConfig(
|
||||||
|
output_folder="/test/output",
|
||||||
|
server_configurations={
|
||||||
|
"host": "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",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_s3_upload():
|
||||||
|
"""Fixture for mocked S3Upload"""
|
||||||
|
with patch("core.recording.worker.services.livekit_api.S3Upload") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_egress_service():
|
||||||
|
"""Fixture for mocked EgressService"""
|
||||||
|
with patch("core.recording.worker.services.EgressService") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(config, mock_s3_upload):
|
||||||
|
"""Fixture for BaseEgressService instance"""
|
||||||
|
return BaseEgressService(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client_session():
|
||||||
|
"""Fixture for mocked aiohttp.ClientSession"""
|
||||||
|
with patch("aiohttp.ClientSession") as mock:
|
||||||
|
mock.return_value.__aenter__ = AsyncMock()
|
||||||
|
mock.return_value.__aexit__ = AsyncMock()
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tcp_connector():
|
||||||
|
"""Fixture for TCPConnector"""
|
||||||
|
with patch("aiohttp.TCPConnector") as mock_connector:
|
||||||
|
mock_connector_instance = Mock()
|
||||||
|
mock_connector.return_value = mock_connector_instance
|
||||||
|
yield mock_connector
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def video_service(config):
|
||||||
|
"""Fixture for VideoCompositeEgressService"""
|
||||||
|
service = VideoCompositeEgressService(config)
|
||||||
|
service._handle_request = Mock() # Mock the request handler
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def audio_service(config):
|
||||||
|
"""Fixture for AudioCompositeEgressService"""
|
||||||
|
service = AudioCompositeEgressService(config)
|
||||||
|
service._handle_request = Mock() # Mock the request handler
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_egress_initialization(config, mock_s3_upload):
|
||||||
|
"""Test service initialization"""
|
||||||
|
|
||||||
|
service = BaseEgressService(config)
|
||||||
|
assert service._config == config
|
||||||
|
|
||||||
|
mock_s3_upload.assert_called_once_with(
|
||||||
|
endpoint="https://s3.test.com",
|
||||||
|
access_key="test_key",
|
||||||
|
secret="test_secret",
|
||||||
|
region="test-region",
|
||||||
|
bucket="test-bucket",
|
||||||
|
force_path_style=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"filename,extension,expected",
|
||||||
|
[
|
||||||
|
("test", "mp4", "/test/output/test.mp4"),
|
||||||
|
("recording123", "ogg", "/test/output/recording123.ogg"),
|
||||||
|
("live_stream", "m3u8", "/test/output/live_stream.m3u8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_base_egress_filepath_construction(service, filename, extension, expected):
|
||||||
|
"""Test filepath construction with various inputs"""
|
||||||
|
result = service._get_filepath(filename, extension)
|
||||||
|
assert result == expected
|
||||||
|
assert result.startswith(service._config.output_folder)
|
||||||
|
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",
|
||||||
|
[
|
||||||
|
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||||
|
"""Test stop method with different response statuses"""
|
||||||
|
# Mock _handle_request
|
||||||
|
mock_response = Mock(status=response_status)
|
||||||
|
service._handle_request = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
# Execute stop
|
||||||
|
result = service.stop("test_worker_id")
|
||||||
|
|
||||||
|
# Verify request and response handling
|
||||||
|
service._handle_request.assert_called_once_with(
|
||||||
|
livekit_api.StopEgressRequest(egress_id="test_worker_id"), "stop_egress"
|
||||||
|
)
|
||||||
|
assert result == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_egress_stop_missing_status(service):
|
||||||
|
"""Test stop method when response is missing status"""
|
||||||
|
# Mock _handle_request with missing status
|
||||||
|
mock_response = Mock(status=None)
|
||||||
|
service._handle_request = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
# Verify error handling
|
||||||
|
with pytest.raises(WorkerResponseError) as exc:
|
||||||
|
service.stop("test_worker_id")
|
||||||
|
|
||||||
|
assert "missing the recording status" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_egress_start_not_implemented(service):
|
||||||
|
"""Test that start method raises NotImplementedError"""
|
||||||
|
with pytest.raises(NotImplementedError) as exc:
|
||||||
|
service.start("test_room", "test_recording")
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_composite_egress_start_success(video_service):
|
||||||
|
"""Test successful start of video composite egress"""
|
||||||
|
# Setup mock response
|
||||||
|
egress_id = "test-egress-123"
|
||||||
|
video_service._handle_request.return_value = Mock(egress_id=egress_id)
|
||||||
|
|
||||||
|
# Test parameters
|
||||||
|
room_name = "test-room"
|
||||||
|
recording_id = "rec-123"
|
||||||
|
|
||||||
|
# Call start
|
||||||
|
result = video_service.start(room_name, recording_id)
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result == egress_id
|
||||||
|
|
||||||
|
# Verify request construction
|
||||||
|
video_service._handle_request.assert_called_once()
|
||||||
|
request = video_service._handle_request.call_args[0][0]
|
||||||
|
method = video_service._handle_request.call_args[0][1]
|
||||||
|
|
||||||
|
# Verify request properties
|
||||||
|
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
||||||
|
assert request.room_name == room_name
|
||||||
|
assert len(request.file_outputs) == 1
|
||||||
|
|
||||||
|
assert not request.audio_only # Video service shouldn't set audio_only
|
||||||
|
|
||||||
|
# Verify file output configuration
|
||||||
|
file_output = request.file_outputs[0]
|
||||||
|
assert file_output.file_type == livekit_api.EncodedFileType.MP4
|
||||||
|
assert file_output.filepath == f"/test/output/{recording_id}.mp4"
|
||||||
|
assert file_output.s3.bucket == "test-bucket"
|
||||||
|
|
||||||
|
# Verify method name
|
||||||
|
assert method == "start_room_composite_egress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_composite_egress_start_missing_egress_id(video_service):
|
||||||
|
"""Test handling of missing egress ID in response"""
|
||||||
|
# Setup mock response without egress_id
|
||||||
|
video_service._handle_request.return_value = Mock(egress_id=None)
|
||||||
|
|
||||||
|
with pytest.raises(WorkerResponseError) as exc_info:
|
||||||
|
video_service.start("test-room", "rec-123")
|
||||||
|
|
||||||
|
assert "Egress ID not found" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_composite_egress_hrid(audio_service):
|
||||||
|
"""Test HRID is correct"""
|
||||||
|
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_composite_egress_start_success(audio_service):
|
||||||
|
"""Test successful start of audio composite egress"""
|
||||||
|
# Setup mock response
|
||||||
|
egress_id = "test-egress-123"
|
||||||
|
audio_service._handle_request.return_value = Mock(egress_id=egress_id)
|
||||||
|
|
||||||
|
# Test parameters
|
||||||
|
room_name = "test-room"
|
||||||
|
recording_id = "rec-123"
|
||||||
|
|
||||||
|
# Call start
|
||||||
|
result = audio_service.start(room_name, recording_id)
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert result == egress_id
|
||||||
|
|
||||||
|
# Verify request construction
|
||||||
|
audio_service._handle_request.assert_called_once()
|
||||||
|
request = audio_service._handle_request.call_args[0][0]
|
||||||
|
method = audio_service._handle_request.call_args[0][1]
|
||||||
|
|
||||||
|
# Verify request properties
|
||||||
|
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
||||||
|
assert request.room_name == room_name
|
||||||
|
assert len(request.file_outputs) == 1
|
||||||
|
assert request.audio_only is True # Audio service should set audio_only
|
||||||
|
|
||||||
|
# Verify file output configuration
|
||||||
|
file_output = request.file_outputs[0]
|
||||||
|
assert file_output.file_type == livekit_api.EncodedFileType.OGG
|
||||||
|
assert file_output.filepath == f"/test/output/{recording_id}.ogg"
|
||||||
|
assert file_output.s3.bucket == "test-bucket"
|
||||||
|
|
||||||
|
# Verify method name
|
||||||
|
assert method == "start_room_composite_egress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_composite_egress_start_missing_egress_id(audio_service):
|
||||||
|
"""Test handling of missing egress ID in response"""
|
||||||
|
# Setup mock response without egress_id
|
||||||
|
audio_service._handle_request.return_value = Mock(egress_id=None)
|
||||||
|
|
||||||
|
with pytest.raises(WorkerResponseError) as exc_info:
|
||||||
|
audio_service.start("test-room", "rec-123")
|
||||||
|
|
||||||
|
assert "Egress ID not found" in str(exc_info.value)
|
||||||
@@ -38,7 +38,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||||
"""It should be possible to get a room by its id stripped of its dashes."""
|
"""It should be possible to get a room by its id stripped of its dashes."""
|
||||||
room = RoomFactory(is_public=False)
|
room = RoomFactory(is_public=False)
|
||||||
id_no_dashes = str(room.id).replace("-", "")
|
id_no_dashes = str(room.id)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/")
|
response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/")
|
||||||
@@ -178,7 +178,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
expected_name = f"{room.id!s}".replace("-", "")
|
expected_name = f"{room.id!s}"
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -220,7 +220,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
expected_name = f"{room.id!s}".replace("-", "")
|
expected_name = f"{room.id!s}"
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -301,6 +301,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
|
"full_name": user_access.user.full_name,
|
||||||
|
"short_name": user_access.user.short_name,
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -310,6 +312,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"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),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -318,7 +322,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_name = str(room.id).replace("-", "")
|
expected_name = str(room.id)
|
||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -374,6 +378,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"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),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -383,6 +389,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
|
"full_name": user_access.user.full_name,
|
||||||
|
"short_name": user_access.user.short_name,
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -390,7 +398,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
expected_name = str(room.id).replace("-", "")
|
expected_name = str(room.id)
|
||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": True,
|
"is_administrable": True,
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Test rooms API endpoints in the Meet core app: start recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621,W0613
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RoomFactory, UserFactory
|
||||||
|
from ...models import Recording
|
||||||
|
from ...recording.worker.exceptions import RecordingStartError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_service():
|
||||||
|
"""Mock worker service."""
|
||||||
|
return mock.Mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_service_factory(mock_worker_service):
|
||||||
|
"""Mock worker service factory."""
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.viewsets.get_worker_service",
|
||||||
|
return_value=mock_worker_service,
|
||||||
|
) as mock_worker_service_factory:
|
||||||
|
yield mock_worker_service_factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_manager(mock_worker_service):
|
||||||
|
"""Mock worker service mediator."""
|
||||||
|
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||||
|
mock_mediator = mock.Mock()
|
||||||
|
mock_mediator_class.return_value = mock_mediator
|
||||||
|
yield mock_mediator
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to start room recordings."""
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_non_owner_and_non_administrator():
|
||||||
|
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_recording_disabled(settings):
|
||||||
|
"""Should fail if recording is disabled for the room."""
|
||||||
|
settings.RECORDING_ENABLE = False
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_missing_mode(settings):
|
||||||
|
"""Should fail if recording mode is not provided."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/start-recording/", {})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "Invalid request."}
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_worker_error(
|
||||||
|
mock_worker_service_factory, mock_worker_manager, settings
|
||||||
|
):
|
||||||
|
"""Should handle worker service errors appropriately."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
# Configure mock mediator to raise error
|
||||||
|
mock_start = mock.Mock()
|
||||||
|
mock_start.side_effect = RecordingStartError("Failed to connect to worker")
|
||||||
|
|
||||||
|
mock_worker_manager.start = mock_start
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {
|
||||||
|
"error": f"Recording failed to start for room {room.slug}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recording object should be created even if worker fails
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
recording = Recording.objects.first()
|
||||||
|
assert recording.room == room
|
||||||
|
assert recording.mode == "screen_recording"
|
||||||
|
|
||||||
|
# Verify recording access details
|
||||||
|
assert recording.accesses.count() == 1
|
||||||
|
access = recording.accesses.first()
|
||||||
|
assert access.user == user
|
||||||
|
assert access.role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_success(
|
||||||
|
mock_worker_service_factory, mock_worker_manager, settings
|
||||||
|
):
|
||||||
|
"""Should successfully start recording when everything is configured correctly."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
mock_start = mock.Mock()
|
||||||
|
mock_worker_manager.start = mock_start
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json() == {
|
||||||
|
"message": f"Recording successfully started for room {room.slug}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify the mediator was called with the recording
|
||||||
|
recording = Recording.objects.first()
|
||||||
|
mock_start.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
assert recording.room == room
|
||||||
|
assert recording.mode == "screen_recording"
|
||||||
|
|
||||||
|
# Verify recording access details
|
||||||
|
assert recording.accesses.count() == 1
|
||||||
|
access = recording.accesses.first()
|
||||||
|
assert access.user == user
|
||||||
|
assert access.role == "owner"
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""
|
||||||
|
Test rooms API endpoints in the Meet core app: stop recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621,W0613
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||||
|
from ...models import Recording, RecordingStatusChoices
|
||||||
|
from ...recording.worker.exceptions import RecordingStopError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_service():
|
||||||
|
"""Mock worker service."""
|
||||||
|
return mock.Mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_service_factory(mock_worker_service):
|
||||||
|
"""Mock worker service factory."""
|
||||||
|
with mock.patch(
|
||||||
|
"core.api.viewsets.get_worker_service",
|
||||||
|
return_value=mock_worker_service,
|
||||||
|
) as mock_worker_service_factory:
|
||||||
|
yield mock_worker_service_factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_worker_manager(mock_worker_service):
|
||||||
|
"""Mock worker service mediator."""
|
||||||
|
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||||
|
mock_mediator = mock.Mock()
|
||||||
|
mock_mediator_class.return_value = mock_mediator
|
||||||
|
yield mock_mediator
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to stop room recordings."""
|
||||||
|
room = RoomFactory()
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
# Verify recording status hasn't changed
|
||||||
|
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_non_owner_and_non_administrator():
|
||||||
|
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
# Verify recording status hasn't changed
|
||||||
|
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_recording_disabled(settings):
|
||||||
|
"""Should fail if recording is disabled for the room."""
|
||||||
|
|
||||||
|
settings.RECORDING_ENABLE = False
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||||
|
# Verify no recording exists
|
||||||
|
assert Recording.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_no_active_recording(settings):
|
||||||
|
"""Should fail when there is no active recording for the room."""
|
||||||
|
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "No active recording found for this room."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_worker_error(
|
||||||
|
mock_worker_service_factory, mock_worker_manager, settings
|
||||||
|
):
|
||||||
|
"""Should handle worker service errors appropriately."""
|
||||||
|
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(
|
||||||
|
room=room,
|
||||||
|
status=RecordingStatusChoices.ACTIVE,
|
||||||
|
mode="screen_recording",
|
||||||
|
)
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
# Configure mock mediator to raise error
|
||||||
|
mock_stop = mock.Mock()
|
||||||
|
mock_stop.side_effect = RecordingStopError("Failed to connect to worker")
|
||||||
|
mock_worker_manager.stop = mock_stop
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||||
|
mock_stop.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {
|
||||||
|
"error": f"Recording failed to stop for room {room.slug}"
|
||||||
|
}
|
||||||
|
# Verify recording status hasn't changed
|
||||||
|
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_recording_success(
|
||||||
|
mock_worker_service_factory, mock_worker_manager, settings
|
||||||
|
):
|
||||||
|
"""Should successfully stop recording when everything is configured correctly."""
|
||||||
|
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(
|
||||||
|
room=room,
|
||||||
|
status=RecordingStatusChoices.ACTIVE,
|
||||||
|
mode="screen_recording",
|
||||||
|
)
|
||||||
|
# Make user the room owner
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
|
||||||
|
mock_stop = mock.Mock()
|
||||||
|
mock_worker_manager.stop = mock_stop
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||||
|
|
||||||
|
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||||
|
mock_stop.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
|
||||||
|
|
||||||
|
# Verify the recording still exists
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"""
|
|
||||||
Test for the Analytics class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0212
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.analytics import Analytics
|
|
||||||
from core.factories import UserFactory
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="mock_june_analytics")
|
|
||||||
def _mock_june_analytics():
|
|
||||||
with patch("core.analytics.jAnalytics") as mock:
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_init_enabled(mock_june_analytics):
|
|
||||||
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
|
|
||||||
analytics = Analytics()
|
|
||||||
assert analytics._enabled is True
|
|
||||||
assert mock_june_analytics.write_key == "test_key"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY=None)
|
|
||||||
def test_analytics_init_disabled():
|
|
||||||
"""Should disable analytics when ANALYTICS_KEY is not set."""
|
|
||||||
analytics = Analytics()
|
|
||||||
assert analytics._enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_identify_user(mock_june_analytics):
|
|
||||||
"""Should identify a user with the correct traits when analytics is enabled."""
|
|
||||||
user = UserFactory(sub="12345", email="user@example.com")
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.identify(user)
|
|
||||||
mock_june_analytics.identify.assert_called_once_with(
|
|
||||||
user_id="12345", traits={"email": "***@example.com"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_identify_user_with_traits(mock_june_analytics):
|
|
||||||
"""Should identify a user with additional traits when analytics is enabled."""
|
|
||||||
user = UserFactory(sub="12345", email="user@example.com")
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
|
|
||||||
mock_june_analytics.identify.assert_called_once_with(
|
|
||||||
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY=None)
|
|
||||||
def test_analytics_identify_not_enabled(mock_june_analytics):
|
|
||||||
"""Should not call identify when analytics is not enabled."""
|
|
||||||
user = UserFactory(sub="12345", email="user@example.com")
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.identify(user)
|
|
||||||
mock_june_analytics.identify.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_identify_no_user(mock_june_analytics):
|
|
||||||
"""Should not call identify when the user is None."""
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.identify(None)
|
|
||||||
mock_june_analytics.identify.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_identify_anonymous_user(mock_june_analytics):
|
|
||||||
"""Should not call identify when the user is anonymous."""
|
|
||||||
user = AnonymousUser()
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.identify(user)
|
|
||||||
mock_june_analytics.identify.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
def test_analytics_track_event(mock_june_analytics):
|
|
||||||
"""Should track an event with the correct user and event details when analytics is enabled."""
|
|
||||||
user = UserFactory(sub="12345")
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.track(user, event="test_event", foo="foo")
|
|
||||||
mock_june_analytics.track.assert_called_once_with(
|
|
||||||
user_id="12345", event="test_event", foo="foo"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY=None)
|
|
||||||
def test_analytics_track_event_not_enabled(mock_june_analytics):
|
|
||||||
"""Should not call track when analytics is not enabled."""
|
|
||||||
user = UserFactory(sub="12345")
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.track(user, event="test_event", foo="foo")
|
|
||||||
|
|
||||||
mock_june_analytics.track.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
|
||||||
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
|
|
||||||
"""Should track an event with a random anonymous user ID when the user is None."""
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.track(None, event="test_event", foo="foo")
|
|
||||||
mock_june_analytics.track.assert_called_once_with(
|
|
||||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
|
||||||
)
|
|
||||||
mock_uuid4.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANALYTICS_KEY="test_key")
|
|
||||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
|
||||||
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
|
|
||||||
"""Should track an event with a random anonymous user ID when the user is anonymous."""
|
|
||||||
user = AnonymousUser()
|
|
||||||
analytics = Analytics()
|
|
||||||
analytics.track(user, event="test_event", foo="foo")
|
|
||||||
mock_june_analytics.track.assert_called_once_with(
|
|
||||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
|
||||||
)
|
|
||||||
mock_uuid4.assert_called_once()
|
|
||||||
@@ -96,6 +96,8 @@ def test_api_users_retrieve_me_authenticated():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"short_name": user.short_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the Recording model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import (
|
||||||
|
RecordingFactory,
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserRecordingAccessFactory,
|
||||||
|
)
|
||||||
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_str():
|
||||||
|
"""The str representation should be the recording ID."""
|
||||||
|
recording = RecordingFactory()
|
||||||
|
assert str(recording) == f"Recording {recording.id} (initiated)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_ordering():
|
||||||
|
"""Recordings should be returned ordered by created_at in descending order."""
|
||||||
|
RecordingFactory.create_batch(3)
|
||||||
|
recordings = Recording.objects.all()
|
||||||
|
assert recordings[0].created_at >= recordings[1].created_at
|
||||||
|
assert recordings[1].created_at >= recordings[2].created_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_room_relationship():
|
||||||
|
"""It should maintain proper relationship with room."""
|
||||||
|
room = RoomFactory()
|
||||||
|
recording = RecordingFactory(room=room)
|
||||||
|
assert recording.room == room
|
||||||
|
assert recording in room.recordings.all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_default_status():
|
||||||
|
"""A new recording should have INITIATED status by default."""
|
||||||
|
recording = RecordingFactory()
|
||||||
|
assert recording.status == RecordingStatusChoices.INITIATED
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_unique_initiated_or_active_per_room():
|
||||||
|
"""Only one initiated or active recording should be allowed per room."""
|
||||||
|
room = RoomFactory()
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.INITIATED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_multiple_finished_allowed():
|
||||||
|
"""Multiple finished recordings should be allowed in the same room."""
|
||||||
|
room = RoomFactory()
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
||||||
|
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
||||||
|
assert room.recordings.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
# Test get_abilities method
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
["owner", "administrator"],
|
||||||
|
)
|
||||||
|
def test_models_recording_get_abilities_privileges_active(role):
|
||||||
|
"""Test abilities for active recording and privileged user."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
access = UserRecordingAccessFactory(role=role, user=user)
|
||||||
|
access.recording.status = RecordingStatusChoices.ACTIVE
|
||||||
|
abilities = access.recording.get_abilities(user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False, # Not final status
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": True, # Privileged users can always retrieve
|
||||||
|
"stop": True, # Not final status, so can stop
|
||||||
|
"update": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_get_abilities_member_active():
|
||||||
|
"""Test abilities for a user who is unprivileged."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
access = UserRecordingAccessFactory(role="member", user=user)
|
||||||
|
access.recording.status = RecordingStatusChoices.ACTIVE
|
||||||
|
abilities = access.recording.get_abilities(user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"stop": False,
|
||||||
|
"update": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
["owner", "administrator"],
|
||||||
|
)
|
||||||
|
def test_models_recording_get_abilities_privileges_final(role):
|
||||||
|
"""Test abilities for active recording and privileged user."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
access = UserRecordingAccessFactory(role=role, user=user)
|
||||||
|
access.recording.status = RecordingStatusChoices.SAVED
|
||||||
|
abilities = access.recording.get_abilities(user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": True, # Privileged users can always retrieve
|
||||||
|
"stop": False, # In final status, so can not stop
|
||||||
|
"update": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_get_abilities_member_final():
|
||||||
|
"""Test abilities for a user who is unprivileged."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
access = UserRecordingAccessFactory(role="member", user=user)
|
||||||
|
access.recording.status = RecordingStatusChoices.SAVED
|
||||||
|
abilities = access.recording.get_abilities(user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"stop": False,
|
||||||
|
"update": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Test is_savable method
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_is_savable_normal():
|
||||||
|
"""Test is_savable for normal recording status."""
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||||
|
assert recording.is_savable() is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status",
|
||||||
|
[
|
||||||
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
|
RecordingStatusChoices.ABORTED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_models_recording_is_savable_error(status):
|
||||||
|
"""Test is_savable for error status."""
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
assert recording.is_savable() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_is_savable_already_saved():
|
||||||
|
"""Test is_savable for already saved recording."""
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
assert recording.is_savable() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_is_savable_only_initiated():
|
||||||
|
"""Test is_savable for only initiated recording."""
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
||||||
|
assert recording.is_savable() is False
|
||||||
|
|
||||||
|
|
||||||
|
# Test few corner cases
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_worker_id_optional():
|
||||||
|
"""Worker ID should be optional."""
|
||||||
|
recording = RecordingFactory(worker_id=None)
|
||||||
|
assert recording.worker_id is None
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-123")
|
||||||
|
assert recording.worker_id == "worker-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_invalid_status():
|
||||||
|
"""Test that setting an invalid status raises an error."""
|
||||||
|
recording = RecordingFactory()
|
||||||
|
recording.status = "INVALID_STATUS"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_room_deletion():
|
||||||
|
"""Test that deleting a room cascades to its recordings."""
|
||||||
|
room = RoomFactory()
|
||||||
|
recording = RecordingFactory(room=room)
|
||||||
|
room.delete()
|
||||||
|
assert not Recording.objects.filter(id=recording.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_worker_id_very_long():
|
||||||
|
"""Test worker_id with maximum length."""
|
||||||
|
long_id = "w" * 255
|
||||||
|
recording = RecordingFactory(worker_id=long_id)
|
||||||
|
assert recording.worker_id == long_id
|
||||||
|
|
||||||
|
too_long_id = "w" * 256
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RecordingFactory(worker_id=too_long_id)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the RecordingAccess model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_str():
|
||||||
|
"""
|
||||||
|
The str representation should include user email, recording ID and role.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(email="david.bowman@example.com")
|
||||||
|
access = factories.UserRecordingAccessFactory(
|
||||||
|
role="member",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
str(access)
|
||||||
|
== f"david.bowman@example.com is member in Recording {access.recording.id} (initiated)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_unique_user():
|
||||||
|
"""Recording accesses should be unique for a given couple of user and recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="This user is already in this recording.",
|
||||||
|
):
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
user=access.user, recording=access.recording
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_several_empty_teams():
|
||||||
|
"""A recording can have several recording accesses with an empty team."""
|
||||||
|
access = factories.UserRecordingAccessFactory()
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_unique_team():
|
||||||
|
"""Recording accesses should be unique for a given couple of team and recording."""
|
||||||
|
access = factories.TeamRecordingAccessFactory()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="This team is already in this recording.",
|
||||||
|
):
|
||||||
|
factories.TeamRecordingAccessFactory(
|
||||||
|
team=access.team, recording=access.recording
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_several_null_users():
|
||||||
|
"""A recording can have several recording accesses with a null user."""
|
||||||
|
access = factories.TeamRecordingAccessFactory()
|
||||||
|
factories.TeamRecordingAccessFactory(recording=access.recording)
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_user_and_team_set():
|
||||||
|
"""User and team can't both be set on a recording access."""
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="Either user or team must be set, not both.",
|
||||||
|
):
|
||||||
|
factories.UserRecordingAccessFactory(team="my-team")
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_accesses_user_and_team_empty():
|
||||||
|
"""User and team can't both be empty on a recording access."""
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="Either user or team must be set, not both.",
|
||||||
|
):
|
||||||
|
factories.UserRecordingAccessFactory(user=None)
|
||||||
|
|
||||||
|
|
||||||
|
# get_abilities
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_anonymous():
|
||||||
|
"""Check abilities returned for an anonymous user."""
|
||||||
|
access = factories.UserRecordingAccessFactory()
|
||||||
|
abilities = access.get_abilities(AnonymousUser())
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_authenticated():
|
||||||
|
"""Check abilities returned for an authenticated user."""
|
||||||
|
access = factories.UserRecordingAccessFactory()
|
||||||
|
user = factories.UserFactory()
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# - for owner
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_owner_of_self_allowed():
|
||||||
|
"""
|
||||||
|
Check abilities of self access for the owner of a recording when
|
||||||
|
there is more than one owner left.
|
||||||
|
"""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="owner")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording, role="owner")
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["administrator", "member"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_owner_of_self_last():
|
||||||
|
"""
|
||||||
|
Check abilities of self access for the owner of a recording when there is only one owner left.
|
||||||
|
"""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="owner")
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_owner_of_owner():
|
||||||
|
"""Check abilities of owner access for the owner of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="owner")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="owner"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["administrator", "member"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_owner_of_administrator():
|
||||||
|
"""Check abilities of administrator access for the owner of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="owner"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["member", "owner"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_owner_of_member():
|
||||||
|
"""Check abilities of member access for the owner of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="member")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="owner"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["administrator", "owner"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# - for administrator
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_administrator_of_owner():
|
||||||
|
"""Check abilities of owner access for the administrator of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="owner")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="administrator"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_administrator_of_administrator():
|
||||||
|
"""Check abilities of administrator access for the administrator of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="administrator"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["member"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_administrator_of_member():
|
||||||
|
"""Check abilities of member access for the administrator of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="member")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="administrator"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["administrator"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# - for member
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_member_of_owner():
|
||||||
|
"""Check abilities of owner access for the member of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="owner")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="member"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_member_of_administrator():
|
||||||
|
"""Check abilities of administrator access for the member of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="member"
|
||||||
|
).user
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_recording_access_get_abilities_for_member_of_member_user(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""Check abilities of member access for the member of a recording."""
|
||||||
|
access = factories.UserRecordingAccessFactory(role="member")
|
||||||
|
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||||
|
user = factories.UserRecordingAccessFactory(
|
||||||
|
recording=access.recording, role="member"
|
||||||
|
).user
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
abilities = access.get_abilities(user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
}
|
||||||
@@ -44,12 +44,3 @@ def test_models_users_send_mail_main_missing():
|
|||||||
user.email_user("my subject", "my message")
|
user.email_user("my subject", "my message")
|
||||||
|
|
||||||
assert str(excinfo.value) == "User has no email address."
|
assert str(excinfo.value) == "User has no email address."
|
||||||
|
|
||||||
|
|
||||||
def test_models_users_email_anonymized():
|
|
||||||
"""The user's email should be anonymized if it exists."""
|
|
||||||
user = factories.UserFactory(email="john.doe@world.com")
|
|
||||||
assert user.email_anonymized == "***@world.com"
|
|
||||||
|
|
||||||
user = factories.UserFactory(email=None)
|
|
||||||
assert user.email_anonymized == ""
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from core.authentication.urls import urlpatterns as oidc_urls
|
|||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("users", viewsets.UserViewSet, basename="users")
|
router.register("users", viewsets.UserViewSet, basename="users")
|
||||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||||
|
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,6 +119,25 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Media
|
||||||
|
AWS_S3_ENDPOINT_URL = values.Value(
|
||||||
|
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_ACCESS_KEY_ID = values.Value(
|
||||||
|
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
||||||
|
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_REGION_NAME = values.Value(
|
||||||
|
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_STORAGE_BUCKET_NAME = values.Value(
|
||||||
|
"meet-media-storage",
|
||||||
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||||
|
|
||||||
@@ -308,6 +327,10 @@ class Base(Configuration):
|
|||||||
default=True,
|
default=True,
|
||||||
environ_name="OIDC_CREATE_USER",
|
environ_name="OIDC_CREATE_USER",
|
||||||
)
|
)
|
||||||
|
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||||
|
default=False,
|
||||||
|
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||||
|
)
|
||||||
OIDC_RP_SIGN_ALGO = values.Value(
|
OIDC_RP_SIGN_ALGO = values.Value(
|
||||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -367,6 +390,16 @@ class Base(Configuration):
|
|||||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||||
|
default=["given_name", "usual_name"],
|
||||||
|
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||||
|
default="given_name",
|
||||||
|
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Video conference configuration
|
# Video conference configuration
|
||||||
LIVEKIT_CONFIGURATION = {
|
LIVEKIT_CONFIGURATION = {
|
||||||
@@ -376,14 +409,44 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||||
}
|
}
|
||||||
DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue(
|
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
|
||||||
True, environ_name="DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None
|
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
|
||||||
)
|
)
|
||||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
ANALYTICS_KEY = values.Value(
|
|
||||||
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
# Recording settings
|
||||||
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"transcript": "core.recording.worker.services.AudioCompositeEgressService",
|
||||||
|
},
|
||||||
|
environ_name="RECORDING_WORKER_CLASSES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
||||||
|
"core.recording.event.parsers.MinioParser",
|
||||||
|
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
||||||
|
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||||
|
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -505,8 +568,6 @@ class Test(Base):
|
|||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||||
|
|
||||||
ANALYTICS_KEY = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -42,7 +42,6 @@ dependencies = [
|
|||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.10",
|
"easy_thumbnails==2.10",
|
||||||
"factory_boy==3.3.1",
|
"factory_boy==3.3.1",
|
||||||
"freezegun==1.5.1",
|
|
||||||
"gunicorn==23.0.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.23.0",
|
"jsonschema==4.23.0",
|
||||||
"june-analytics-python==2.3.0",
|
"june-analytics-python==2.3.0",
|
||||||
@@ -58,6 +57,7 @@ dependencies = [
|
|||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.7.0",
|
"livekit-api==0.7.0",
|
||||||
|
"aiohttp==3.10.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -70,6 +70,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2024.7.1",
|
"drf-spectacular-sidecar==2024.7.1",
|
||||||
|
"freezegun==1.5.1",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.27.0",
|
"ipython==8.27.0",
|
||||||
"pyfakefs==5.6.0",
|
"pyfakefs==5.6.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine as frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ COPY .dockerignore ./.dockerignore
|
|||||||
COPY ./src/frontend/ .
|
COPY ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps as meet
|
FROM frontend-deps AS meet
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
FROM frontend-deps as meet-dev
|
FROM frontend-deps AS meet-dev
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ CMD [ "npm", "run", "dev"]
|
|||||||
|
|
||||||
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
||||||
# to avoid rebuilding the app at every changes.
|
# to avoid rebuilding the app at every changes.
|
||||||
FROM meet as meet-builder
|
FROM meet AS meet-builder
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
|
|||||||
Generated
+21
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.6.5",
|
"@livekit/components-react": "2.6.5",
|
||||||
"@livekit/components-styles": "1.1.3",
|
"@livekit/components-styles": "1.1.3",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"i18next-parser": "9.0.2",
|
"i18next-parser": "9.0.2",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.5.7",
|
"livekit-client": "2.5.7",
|
||||||
"posthog-js": "1.167.0",
|
"posthog-js": "1.186.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.4.0",
|
"react-aria-components": "1.4.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
@@ -409,6 +409,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
|
"extraneous": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4529,6 +4530,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||||
|
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -8253,13 +8265,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.167.0",
|
"version": "1.186.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.167.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.186.1.tgz",
|
||||||
"integrity": "sha512-/zXQ6tuJgiF1d4mgg3UsAi/uoyg7UnfFNQtikuALmaE53xFExpcAKbMfHPG/f54QgTvLxSHyGL1kFl/1uspkGg==",
|
"integrity": "sha512-m6TNW01nfqErwMxaZxNScYdMaUJO0s3bbmt/tboL29yZDnuHdOiYFbG+T4MCxdFxjWRa5gOR25bQD/SSt1t/4A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"core-js": "^3.38.1",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.19.3",
|
"preact": "^10.19.3",
|
||||||
"web-vitals": "^4.0.1"
|
"web-vitals": "^4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"i18next-parser": "9.0.2",
|
"i18next-parser": "9.0.2",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.5.7",
|
"livekit-client": "2.5.7",
|
||||||
"posthog-js": "1.167.0",
|
"posthog-js": "1.186.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.4.0",
|
"react-aria-components": "1.4.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const config: Config = {
|
|||||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
||||||
|
xsm: '31.25em', // 500px,
|
||||||
sm: '40em', // 640px
|
sm: '40em', // 640px
|
||||||
md: '48em', // 768px
|
md: '48em', // 768px
|
||||||
lg: '64em', // 1024px
|
lg: '64em', // 1024px
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { fetchApi } from './fetchApi'
|
|||||||
import { keys } from './queryKeys'
|
import { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// todo - refactor it in a proper place
|
||||||
|
export enum RecordingMode {
|
||||||
|
Transcript = 'transcript',
|
||||||
|
ScreenRecording = 'screen_recording',
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiConfig {
|
export interface ApiConfig {
|
||||||
analytics?: {
|
analytics?: {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +17,10 @@ export interface ApiConfig {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
|
recording?: {
|
||||||
|
is_enabled?: boolean
|
||||||
|
available_modes?: RecordingMode[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 635 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 599 KiB |
@@ -1,25 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiExternalLinkLine } from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { LinkButton } from '@/primitives'
|
|
||||||
|
|
||||||
export const Feedback = () => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
|
||||||
<LinkButton
|
|
||||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
|
||||||
variant="success"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
|
|
||||||
💡
|
|
||||||
</span>
|
|
||||||
{t('feedbackAlert')}
|
|
||||||
<RiExternalLinkLine
|
|
||||||
size={16}
|
|
||||||
className={css({ marginLeft: 0.5 })}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</LinkButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Text, A } from '@/primitives'
|
||||||
|
|
||||||
|
const GRIST_FORM =
|
||||||
|
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
|
||||||
|
|
||||||
|
export const FeedbackBanner = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#E8EDFF',
|
||||||
|
color: '#0063CB',
|
||||||
|
display: { base: 'none', sm: 'flex' },
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiErrorWarningLine size={20} />
|
||||||
|
<Text as="p">{t('feedback.context')}</Text>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.25,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<A href={GRIST_FORM} target="_blank">
|
||||||
|
{t('feedback.cta')}
|
||||||
|
</A>
|
||||||
|
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
full_name: string
|
||||||
|
last_name: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import firstSlide from '@/assets/intro-slider/1_solo.png'
|
||||||
|
import secondSlide from '@/assets/intro-slider/2_multiple.png'
|
||||||
|
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||||
|
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Button, LinkButton } from '@/primitives'
|
||||||
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
// todo - extract in a proper env variable
|
||||||
|
const BETA_USERS_FORM_URL =
|
||||||
|
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
||||||
|
|
||||||
|
const Heading = styled('h2', {
|
||||||
|
base: {
|
||||||
|
width: 'fit-content',
|
||||||
|
marginBottom: 0,
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
lineHeight: '2rem',
|
||||||
|
maxWidth: '23rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
textWrap: 'pretty',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Body = styled('p', {
|
||||||
|
base: {
|
||||||
|
maxWidth: '23rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
textWrap: 'pretty',
|
||||||
|
lineHeight: '1.2rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Image = styled('img', {
|
||||||
|
base: {
|
||||||
|
maxHeight: '362px',
|
||||||
|
height: '100%',
|
||||||
|
width: 'fit-content',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Dot = styled('div', {
|
||||||
|
base: {
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-block',
|
||||||
|
height: '.375rem',
|
||||||
|
margin: '0 .25rem',
|
||||||
|
width: '.375rem',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
selected: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: '#000091',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
backgroundColor: '#CACAFB',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: { base: 'none', xsm: 'block' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonVerticalCenter = styled('div', {
|
||||||
|
base: {
|
||||||
|
marginTop: '13.3125rem',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SlideContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
alignItems: 'stretch',
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Slide = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
justifyContent: 'start',
|
||||||
|
minHeight: { base: 'none', xsm: '550px' },
|
||||||
|
minWidth: { base: 'none', xsm: '200px' },
|
||||||
|
width: { base: '100%', xsm: '22.625rem' },
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
visible: {
|
||||||
|
true: {
|
||||||
|
visibility: 'visible',
|
||||||
|
position: 'static',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
visibility: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextAnimation = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
visible: {
|
||||||
|
true: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'none',
|
||||||
|
transition: 'opacity ease-in .3s, transform ease-in .3s',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateX(-30%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Slide = {
|
||||||
|
key: string
|
||||||
|
img: string
|
||||||
|
isAvailableInBeta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo - optimize how images are imported
|
||||||
|
const SLIDES: Slide[] = [
|
||||||
|
{
|
||||||
|
key: 'slide1',
|
||||||
|
img: firstSlide,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slide2',
|
||||||
|
img: secondSlide,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slide3',
|
||||||
|
img: thirdSlide,
|
||||||
|
isAvailableInBeta: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IntroSlider = () => {
|
||||||
|
const [slideIndex, setSlideIndex] = useState(0)
|
||||||
|
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||||
|
const NUMBER_SLIDES = SLIDES.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ButtonContainer>
|
||||||
|
<ButtonVerticalCenter>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
aria-label={t('previous.label')}
|
||||||
|
tooltip={t('previous.tooltip')}
|
||||||
|
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||||
|
isDisabled={slideIndex == 0}
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine />
|
||||||
|
</Button>
|
||||||
|
</ButtonVerticalCenter>
|
||||||
|
</ButtonContainer>
|
||||||
|
<SlideContainer>
|
||||||
|
{SLIDES.map((slide, index) => (
|
||||||
|
<Slide visible={index == slideIndex} key={index}>
|
||||||
|
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
|
||||||
|
<TextAnimation visible={index == slideIndex}>
|
||||||
|
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||||
|
<Body>{t(`${slide.key}.body`)}</Body>
|
||||||
|
{slide.isAvailableInBeta && (
|
||||||
|
<LinkButton
|
||||||
|
href={BETA_USERS_FORM_URL}
|
||||||
|
tooltip={t('beta.tooltip')}
|
||||||
|
variant={'primary'}
|
||||||
|
size={'sm'}
|
||||||
|
style={{ marginTop: '1rem', width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{t('beta.text')}
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</TextAnimation>
|
||||||
|
</Slide>
|
||||||
|
))}
|
||||||
|
</SlideContainer>
|
||||||
|
<ButtonContainer>
|
||||||
|
<ButtonVerticalCenter>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
aria-label={t('next.label')}
|
||||||
|
tooltip={t('next.tooltip')}
|
||||||
|
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||||
|
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine />
|
||||||
|
</Button>
|
||||||
|
</ButtonVerticalCenter>
|
||||||
|
</ButtonContainer>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
display: { base: 'none', xsm: 'block' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{SLIDES.map((_, index) => (
|
||||||
|
<Dot key={index} selected={index == slideIndex} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { A, Text } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const MANIFEST_LINK =
|
||||||
|
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
|
||||||
|
|
||||||
|
export const MoreLink = () => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
||||||
|
<A
|
||||||
|
href={MANIFEST_LINK}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t('moreLinkLabel')}
|
||||||
|
>
|
||||||
|
{t('moreLink')}
|
||||||
|
</A>{' '}
|
||||||
|
{t('moreAbout')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
import { Button, Menu, Text } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { Centered } from '@/layout/Centered'
|
|
||||||
import { generateRoomId } from '@/features/rooms'
|
import { generateRoomId } from '@/features/rooms'
|
||||||
import { useUser, UserAware } from '@/features/auth'
|
import { useUser, UserAware } from '@/features/auth'
|
||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
@@ -14,7 +13,132 @@ import { usePersistentUserChoices } from '@livekit/components-react'
|
|||||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
import { useState } from 'react'
|
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||||
|
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||||
|
import { ReactNode, useState } from 'react'
|
||||||
|
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
alignItems: 'center',
|
||||||
|
margin: 'auto',
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '100%',
|
||||||
|
justifyContent: 'normal',
|
||||||
|
padding: '0 1rem',
|
||||||
|
width: 'calc(100% - 2rem)',
|
||||||
|
opacity: 0,
|
||||||
|
animation: '.5s ease-in fade 0s forwards',
|
||||||
|
lg: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
alignItems: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexBasis: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: '38rem',
|
||||||
|
width: '100%',
|
||||||
|
padding: '1rem 3%',
|
||||||
|
marginTop: 'auto',
|
||||||
|
lg: {
|
||||||
|
margin: 0,
|
||||||
|
textAlign: 'left',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
flexShrink: '1',
|
||||||
|
flexBasis: '36rem',
|
||||||
|
maxWidth: '36rem',
|
||||||
|
padding: '1em 3em',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightColumn = ({ children }: { children?: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: '1rem 3%',
|
||||||
|
marginBottom: 'auto',
|
||||||
|
flexBasis: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: '39rem',
|
||||||
|
lg: {
|
||||||
|
margin: 0,
|
||||||
|
flexBasis: '45%',
|
||||||
|
padding: '1em 3em',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Separator = styled('div', {
|
||||||
|
base: {
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: '#747775',
|
||||||
|
marginTop: '2.5rem',
|
||||||
|
maxWidth: '30rem',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Heading = styled('h1', {
|
||||||
|
base: {
|
||||||
|
fontWeight: '500',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontStretch: 'normal',
|
||||||
|
fontOpticalSizing: 'auto',
|
||||||
|
marginBottom: 0,
|
||||||
|
paddingBottom: '0.75rem',
|
||||||
|
fontSize: '2.3rem',
|
||||||
|
lineHeight: '2.5rem',
|
||||||
|
letterSpacing: '0',
|
||||||
|
xsm: {
|
||||||
|
fontSize: '3rem',
|
||||||
|
lineHeight: '3.2rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const IntroText = styled('div', {
|
||||||
|
base: {
|
||||||
|
marginBottom: '3rem',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
lineHeight: '1.8rem',
|
||||||
|
textWrap: 'pretty',
|
||||||
|
maxWidth: '32rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
@@ -30,72 +154,90 @@ export const Home = () => {
|
|||||||
return (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
<Screen>
|
<Screen>
|
||||||
<Centered width="fit-content">
|
<Columns>
|
||||||
<Text as="h1" variant="display">
|
<LeftColumn>
|
||||||
{t('heading')}
|
<Heading>{t('heading')}</Heading>
|
||||||
</Text>
|
<IntroText>{t('intro')}</IntroText>
|
||||||
<Text as="p" variant="h3">
|
<div
|
||||||
{t('intro')}
|
className={css({
|
||||||
</Text>
|
display: 'flex',
|
||||||
{!isLoggedIn && (
|
gap: 0.5,
|
||||||
<Text margin="sm" variant="note">
|
flexDirection: { base: 'column', xsm: 'row' },
|
||||||
{t('loginToCreateMeeting')}
|
alignItems: { base: 'center', xsm: 'items-start' },
|
||||||
</Text>
|
})}
|
||||||
)}
|
>
|
||||||
<HStack gap="gutter" alignItems="start">
|
{isLoggedIn ? (
|
||||||
{isLoggedIn ? (
|
<Menu>
|
||||||
<Menu>
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
{t('createMeeting')}
|
||||||
{t('createMeeting')}
|
</Button>
|
||||||
|
<RACMenu>
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={async () => {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
createRoom({ slug, username }).then((data) =>
|
||||||
|
navigateTo('room', data.slug, {
|
||||||
|
state: { create: true, initialRoomData: data },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
data-attr="create-option-instant"
|
||||||
|
>
|
||||||
|
<RiAddLine size={18} />
|
||||||
|
{t('createMenu.instantOption')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={() => {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
createRoom({ slug, username }).then((data) =>
|
||||||
|
setLaterRoomId(data.slug)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
data-attr="create-option-later"
|
||||||
|
>
|
||||||
|
<RiLink size={18} />
|
||||||
|
{t('createMenu.laterOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<ProConnectButton hint={false} />
|
||||||
|
)}
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
outline
|
||||||
|
style={{
|
||||||
|
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('joinMeeting')}
|
||||||
</Button>
|
</Button>
|
||||||
<RACMenu>
|
<JoinMeetingDialog />
|
||||||
<MenuItem
|
</DialogTrigger>
|
||||||
className={menuItemRecipe({ icon: true })}
|
</div>
|
||||||
onAction={async () => {
|
<Separator />
|
||||||
const slug = generateRoomId()
|
<div
|
||||||
createRoom({ slug, username }).then((data) =>
|
className={css({
|
||||||
navigateTo('room', data.slug, {
|
display: { base: 'none', lg: 'inline' },
|
||||||
state: { create: true, initialRoomData: data },
|
})}
|
||||||
})
|
>
|
||||||
)
|
<MoreLink />
|
||||||
}}
|
</div>
|
||||||
data-attr="create-option-instant"
|
</LeftColumn>
|
||||||
>
|
<RightColumn>
|
||||||
<RiAddLine size={18} />
|
<IntroSlider />
|
||||||
{t('createMenu.instantOption')}
|
<div
|
||||||
</MenuItem>
|
className={css({
|
||||||
<MenuItem
|
display: { base: 'inline', lg: 'none' },
|
||||||
className={menuItemRecipe({ icon: true })}
|
})}
|
||||||
onAction={() => {
|
>
|
||||||
const slug = generateRoomId()
|
<MoreLink />
|
||||||
createRoom({ slug, username }).then((data) =>
|
</div>
|
||||||
setLaterRoomId(data.slug)
|
</RightColumn>
|
||||||
)
|
</Columns>
|
||||||
}}
|
|
||||||
data-attr="create-option-later"
|
|
||||||
>
|
|
||||||
<RiLink size={18} />
|
|
||||||
{t('createMenu.laterOption')}
|
|
||||||
</MenuItem>
|
|
||||||
</RACMenu>
|
|
||||||
</Menu>
|
|
||||||
) : (
|
|
||||||
<ProConnectButton />
|
|
||||||
)}
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
outline
|
|
||||||
style={{
|
|
||||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('joinMeeting')}
|
|
||||||
</Button>
|
|
||||||
<JoinMeetingDialog />
|
|
||||||
</DialogTrigger>
|
|
||||||
</HStack>
|
|
||||||
</Centered>
|
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
roomId={laterRoomId || ''}
|
roomId={laterRoomId || ''}
|
||||||
onOpenChange={() => setLaterRoomId(null)}
|
onOpenChange={() => setLaterRoomId(null)}
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||||
|
// These settings help ensure successful connections in poor network conditions
|
||||||
|
const connectOptions = {
|
||||||
|
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||||
|
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false}>
|
<Screen header={false}>
|
||||||
@@ -99,6 +106,7 @@ export const Conference = ({
|
|||||||
connect={true}
|
connect={true}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
|
connectOptions={connectOptions}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { CenteredContent } from '@/layout/CenteredContent'
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
|
||||||
export const Join = ({
|
export const Join = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -9,6 +10,7 @@ export const Join = ({
|
|||||||
onSubmit: (choices: LocalUserChoices) => void
|
onSubmit: (choices: LocalUserChoices) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const { user } = useUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered">
|
<Screen layout="centered">
|
||||||
@@ -19,7 +21,8 @@ export const Join = ({
|
|||||||
micLabel={t('join.audioinput.label')}
|
micLabel={t('join.audioinput.label')}
|
||||||
camLabel={t('join.videoinput.label')}
|
camLabel={t('join.videoinput.label')}
|
||||||
joinLabel={t('join.joinLabel')}
|
joinLabel={t('join.joinLabel')}
|
||||||
userLabel={t('join.userLabel')}
|
userLabel={t('join.usernameLabel')}
|
||||||
|
defaults={{ username: user?.full_name }}
|
||||||
/>
|
/>
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { Button, H, Text, TextArea } from '@/primitives'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { cva } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { usePostHog } from 'posthog-js/react'
|
||||||
|
import { PostHog } from 'posthog-js'
|
||||||
|
|
||||||
|
const Card = styled('div', {
|
||||||
|
base: {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'gray.300',
|
||||||
|
padding: '1rem',
|
||||||
|
marginTop: '1.5rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
boxShadow: '',
|
||||||
|
minWidth: '380px',
|
||||||
|
minHeight: '196px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Bar = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'gray.300',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
scrollbar: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ratingButtonRecipe = cva({
|
||||||
|
base: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
color: 'initial',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: '0.5rem 0.85rem',
|
||||||
|
flexGrow: '1',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
selected: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: '#1d4ed8',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderLeft: {
|
||||||
|
true: {
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderColor: 'gray.300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelRecipe = cva({
|
||||||
|
base: {
|
||||||
|
color: 'gray.600',
|
||||||
|
paddingTop: '0.25rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const OpenFeedback = ({
|
||||||
|
posthog,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
posthog: PostHog
|
||||||
|
onNext: () => void
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
|
||||||
|
const [feedback, setFeedback] = useState('')
|
||||||
|
|
||||||
|
const onContinue = () => {
|
||||||
|
setFeedback('')
|
||||||
|
onNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
try {
|
||||||
|
posthog.capture('survey sent', {
|
||||||
|
$survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
|
||||||
|
$survey_response: feedback,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e)
|
||||||
|
} finally {
|
||||||
|
onContinue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<H lvl={3}>{t('question')}</H>
|
||||||
|
<TextArea
|
||||||
|
id="feedbackInput"
|
||||||
|
name="feedback"
|
||||||
|
placeholder={t('placeholder')}
|
||||||
|
required
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
style={{
|
||||||
|
minHeight: '150px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VStack gap="0.5">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
isDisabled={!feedback}
|
||||||
|
onPress={onSubmit}
|
||||||
|
>
|
||||||
|
{t('submit')}
|
||||||
|
</Button>
|
||||||
|
<Button invisible size="sm" fullWidth onPress={onNext}>
|
||||||
|
{t('skip')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RateQuality = ({
|
||||||
|
posthog,
|
||||||
|
onNext,
|
||||||
|
maxRating = 7,
|
||||||
|
}: {
|
||||||
|
posthog: PostHog
|
||||||
|
onNext: () => void
|
||||||
|
maxRating?: number
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
|
||||||
|
const [selectedRating, setSelectedRating] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleRatingClick = (rating: number) => {
|
||||||
|
setSelectedRating((prevRating) => (prevRating === rating ? null : rating))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
try {
|
||||||
|
posthog.capture('survey sent', {
|
||||||
|
$survey_id: '01933c22-d005-0000-b623-20b752171e2e',
|
||||||
|
$survey_response: `${selectedRating}`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e)
|
||||||
|
} finally {
|
||||||
|
setSelectedRating(null)
|
||||||
|
onNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<H lvl={3}>{t('question')}</H>
|
||||||
|
<Bar>
|
||||||
|
{[...Array(maxRating)].map((_, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
onPress={() => handleRatingClick(index + 1)}
|
||||||
|
className={ratingButtonRecipe({
|
||||||
|
selected: selectedRating === index + 1,
|
||||||
|
borderLeft: index != 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="sm" className={labelRecipe()}>
|
||||||
|
{t('levels.min')}
|
||||||
|
</Text>
|
||||||
|
<Text variant="sm" className={labelRecipe()}>
|
||||||
|
{t('levels.max')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
isDisabled={!selectedRating}
|
||||||
|
onPress={onSubmit}
|
||||||
|
>
|
||||||
|
{t('submit')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'confirmationMessage' })
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onNext()
|
||||||
|
}, 10000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [onNext])
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
maxWidth: '380px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<H lvl={3}>{t('heading')}</H>
|
||||||
|
<Text as="p" variant="paragraph" centered>
|
||||||
|
{t('body')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Rating = () => {
|
||||||
|
const posthog = usePostHog()
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
|
if (step == 0) {
|
||||||
|
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 1) {
|
||||||
|
return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 2) {
|
||||||
|
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Button } from '@/primitives'
|
import { Button, TextArea } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { TextArea } from '@/primitives/TextArea'
|
|
||||||
import { RefObject } from 'react'
|
import { RefObject } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
const { isSidePanelOpen } = useSidePanel()
|
const { isSidePanelOpen } = useSidePanel()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lk-video-conference" {...props}>
|
<div
|
||||||
|
className="lk-video-conference"
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
overflowX: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isWeb() && (
|
{isWeb() && (
|
||||||
<LayoutContextProvider
|
<LayoutContextProvider
|
||||||
value={layoutContext}
|
value={layoutContext}
|
||||||
|
|||||||
@@ -1,17 +1,47 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Text } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { CenteredContent } from '@/layout/CenteredContent'
|
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||||
|
import { useLocation } from 'wouter'
|
||||||
|
|
||||||
|
// fixme - duplicated with home, refactor in a proper style
|
||||||
|
const Heading = styled('h1', {
|
||||||
|
base: {
|
||||||
|
fontWeight: '500',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontStretch: 'normal',
|
||||||
|
fontOpticalSizing: 'auto',
|
||||||
|
fontSize: '2.3rem',
|
||||||
|
lineHeight: '2.5rem',
|
||||||
|
letterSpacing: '0',
|
||||||
|
paddingBottom: '2rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const FeedbackRoute = () => {
|
export const FeedbackRoute = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const [, setLocation] = useLocation()
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered">
|
<Screen layout="centered">
|
||||||
<CenteredContent title={t('feedback.heading')} withBackButton>
|
<Center>
|
||||||
<Text as="p" variant="h3" centered>
|
<VStack>
|
||||||
{t('feedback.body')}
|
<Heading>{t('feedback.heading')}</Heading>
|
||||||
</Text>
|
<HStack>
|
||||||
</CenteredContent>
|
<Button
|
||||||
|
outline
|
||||||
|
variant="primary"
|
||||||
|
onPress={() => window.history.back()}
|
||||||
|
>
|
||||||
|
{t('feedback.back')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||||
|
{t('feedback.home')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
<Rating />
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
</Screen>
|
</Screen>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.email }}
|
values={{ user: user?.full_name || user?.email }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.email }}
|
values={{ user: user?.full_name || user?.email }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ i18n
|
|||||||
)
|
)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
i18n.init({
|
.init({
|
||||||
supportedLngs: ['en', 'fr'],
|
supportedLngs: ['en', 'fr'],
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'fr',
|
||||||
ns: i18nDefaultNamespace,
|
ns: i18nDefaultNamespace,
|
||||||
interpolation: {
|
detection: {
|
||||||
escapeValue: false,
|
order: ['localStorage', 'navigator'],
|
||||||
},
|
},
|
||||||
})
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Text, Button } from '@/primitives'
|
|||||||
import { SettingsButton } from '@/features/settings'
|
import { SettingsButton } from '@/features/settings'
|
||||||
import { logoutUrl, useUser } from '@/features/auth'
|
import { logoutUrl, useUser } from '@/features/auth'
|
||||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||||
import { Feedback } from '@/components/Feedback'
|
import { FeedbackBanner } from '@/components/FeedbackBanner.tsx'
|
||||||
import { Menu } from '@/primitives/Menu'
|
import { Menu } from '@/primitives/Menu'
|
||||||
import { MenuList } from '@/primitives/MenuList'
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||||
@@ -20,80 +20,85 @@ export const Header = () => {
|
|||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={css({
|
<FeedbackBanner />
|
||||||
borderBottomColor: 'box.border',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
paddingY: 1,
|
|
||||||
paddingX: 1,
|
|
||||||
flexShrink: 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
paddingY: 1,
|
||||||
flexDirection: 'column',
|
paddingX: 1,
|
||||||
rowGap: 1,
|
flexShrink: 0,
|
||||||
md: {
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
rowGap: 0,
|
rowGap: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
})}
|
||||||
})}
|
>
|
||||||
>
|
<header>
|
||||||
<header>
|
<Stack gap={2.25} direction="row" align="center">
|
||||||
<Stack gap={2.25} direction="row" align="center">
|
<Text bold variant="h1" margin={false}>
|
||||||
<Text bold variant="h1" margin={false}>
|
<Link
|
||||||
<Link
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
if (
|
||||||
if (
|
isRoom &&
|
||||||
isRoom &&
|
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
||||||
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
) {
|
||||||
) {
|
event.preventDefault()
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
to="/"
|
|
||||||
>
|
|
||||||
{t('app')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
<Feedback />
|
|
||||||
</Stack>
|
|
||||||
</header>
|
|
||||||
<nav>
|
|
||||||
<Stack gap={1} direction="row" align="center">
|
|
||||||
{isLoggedIn === false && !isHome && (
|
|
||||||
<ProConnectButton hint={false} />
|
|
||||||
)}
|
|
||||||
{!!user && (
|
|
||||||
<Menu>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
invisible
|
|
||||||
tooltip={t('loggedInUserTooltip')}
|
|
||||||
tooltipType="delayed"
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</Button>
|
|
||||||
<MenuList
|
|
||||||
items={[{ value: 'logout', label: t('logout') }]}
|
|
||||||
onAction={(value) => {
|
|
||||||
if (value === 'logout') {
|
|
||||||
terminateAnalyticsSession()
|
|
||||||
terminateSupportSession()
|
|
||||||
window.location.href = logoutUrl()
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
to="/"
|
||||||
</Menu>
|
>
|
||||||
)}
|
{t('app')}
|
||||||
<SettingsButton />
|
</Link>
|
||||||
</Stack>
|
</Text>
|
||||||
</nav>
|
</Stack>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<Stack gap={1} direction="row" align="center">
|
||||||
|
{isLoggedIn === false && !isHome && (
|
||||||
|
<ProConnectButton hint={false} />
|
||||||
|
)}
|
||||||
|
{!!user && (
|
||||||
|
<Menu>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
invisible
|
||||||
|
tooltip={t('loggedInUserTooltip')}
|
||||||
|
tooltipType="delayed"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: { base: '80px', xsm: '350px' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{user?.full_name || user?.email}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<MenuList
|
||||||
|
items={[{ value: 'logout', label: t('logout') }]}
|
||||||
|
onAction={(value) => {
|
||||||
|
if (value === 'logout') {
|
||||||
|
terminateAnalyticsSession()
|
||||||
|
terminateSupportSession()
|
||||||
|
window.location.href = logoutUrl()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
<SettingsButton />
|
||||||
|
</Stack>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
"feedbackAlert": "",
|
"feedback": {
|
||||||
|
"context": "",
|
||||||
|
"cta": ""
|
||||||
|
},
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"joinMeetingTipContent": "",
|
"joinMeetingTipContent": "",
|
||||||
"joinMeetingTipHeading": "",
|
"joinMeetingTipHeading": "",
|
||||||
"loginToCreateMeeting": "",
|
"loginToCreateMeeting": "",
|
||||||
|
"moreLinkLabel": "",
|
||||||
|
"moreLink": "",
|
||||||
|
"moreAbout": "",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "",
|
"laterOption": "",
|
||||||
"instantOption": ""
|
"instantOption": ""
|
||||||
@@ -20,5 +23,34 @@
|
|||||||
"copy": "",
|
"copy": "",
|
||||||
"copied": "",
|
"copied": "",
|
||||||
"permissions": ""
|
"permissions": ""
|
||||||
|
},
|
||||||
|
"introSlider": {
|
||||||
|
"previous": {
|
||||||
|
"label": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"beta": {
|
||||||
|
"text": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"label": "",
|
||||||
|
"tooltip": ""
|
||||||
|
},
|
||||||
|
"slide1": {
|
||||||
|
"title": "",
|
||||||
|
"body": "",
|
||||||
|
"imgAlt": ""
|
||||||
|
},
|
||||||
|
"slide2": {
|
||||||
|
"title": "",
|
||||||
|
"body": "",
|
||||||
|
"imgAlt": ""
|
||||||
|
},
|
||||||
|
"slide3": {
|
||||||
|
"title": "",
|
||||||
|
"body": "",
|
||||||
|
"imgAlt": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"body": "",
|
"heading": "",
|
||||||
"heading": ""
|
"home": "",
|
||||||
|
"back": ""
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
"joinMeeting": "",
|
"joinMeeting": "",
|
||||||
"toggleOff": "",
|
"toggleOff": "",
|
||||||
"toggleOn": "",
|
"toggleOn": "",
|
||||||
"userLabel": "",
|
|
||||||
"usernameHint": "",
|
"usernameHint": "",
|
||||||
"usernameLabel": ""
|
"usernameLabel": ""
|
||||||
},
|
},
|
||||||
@@ -110,6 +110,24 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": ""
|
"disclaimer": ""
|
||||||
},
|
},
|
||||||
|
"rating": {
|
||||||
|
"submit": "",
|
||||||
|
"question": "",
|
||||||
|
"levels": {
|
||||||
|
"min": "",
|
||||||
|
"max": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openFeedback": {
|
||||||
|
"question": "",
|
||||||
|
"placeholder": "",
|
||||||
|
"submit": "",
|
||||||
|
"skip": ""
|
||||||
|
},
|
||||||
|
"confirmationMessage": {
|
||||||
|
"heading": "",
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "",
|
"subheading": "",
|
||||||
"contributors": "",
|
"contributors": "",
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": "An error occurred while loading the page"
|
"heading": "An error occurred while loading the page"
|
||||||
},
|
},
|
||||||
"feedbackAlert": "Give us feedback",
|
"feedback": {
|
||||||
|
"context": "Visio is still in early development — your input matters!",
|
||||||
|
"cta": "Share your feedback"
|
||||||
|
},
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "You don't have the permission to view this page"
|
"heading": "You don't have the permission to view this page"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"createMeeting": "Create a meeting",
|
"createMeeting": "Create a meeting",
|
||||||
"heading": "Welcome in Visio",
|
"heading": "Simple and Secure Video Conferencing",
|
||||||
"intro": "Work easily, from anywhere.",
|
"intro": "Communicate and work with ease, without compromising your sovereignty",
|
||||||
"joinInputError": "Use a meeting link or code. Examples:",
|
"joinInputError": "Use a meeting link or code. Examples:",
|
||||||
"joinInputExample": "URL or 10-letter code",
|
"joinInputExample": "URL or 10-letter code",
|
||||||
"joinInputLabel": "Meeting link",
|
"joinInputLabel": "Meeting link",
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting",
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
|
"moreLinkLabel": "Learn more - new tab",
|
||||||
|
"moreLink": "Learn more",
|
||||||
|
"moreAbout": "about Visio",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Create a meeting for a later date",
|
"laterOption": "Create a meeting for a later date",
|
||||||
"instantOption": "Start an instant meeting"
|
"instantOption": "Start an instant meeting"
|
||||||
@@ -20,5 +23,34 @@
|
|||||||
"copy": "Copy the meeting link",
|
"copy": "Copy the meeting link",
|
||||||
"copied": "Link copied to clipboard",
|
"copied": "Link copied to clipboard",
|
||||||
"permissions": "People with this link do not need your permission to join this meeting."
|
"permissions": "People with this link do not need your permission to join this meeting."
|
||||||
|
},
|
||||||
|
"introSlider": {
|
||||||
|
"previous": {
|
||||||
|
"label": "previous",
|
||||||
|
"tooltip": "previous"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"label": "next",
|
||||||
|
"tooltip": "next"
|
||||||
|
},
|
||||||
|
"beta": {
|
||||||
|
"text": "Join the beta",
|
||||||
|
"tooltip": "Fill out the form"
|
||||||
|
},
|
||||||
|
"slide1": {
|
||||||
|
"title": "Try Visio to simplify your daily tasks",
|
||||||
|
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
|
||||||
|
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
|
||||||
|
},
|
||||||
|
"slide2": {
|
||||||
|
"title": "Host group calls without limits",
|
||||||
|
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
|
||||||
|
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
|
||||||
|
},
|
||||||
|
"slide3": {
|
||||||
|
"title": "Transform your meetings with AI",
|
||||||
|
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
|
||||||
|
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
|
"heading": "You have left the meeting",
|
||||||
"heading": "Help us improve Visio"
|
"home": "Return to home",
|
||||||
|
"back": "Rejoin the meeting"
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
"joinMeeting": "Join meeting",
|
"joinMeeting": "Join meeting",
|
||||||
"toggleOff": "Click to turn off",
|
"toggleOff": "Click to turn off",
|
||||||
"toggleOn": "Click to turn on",
|
"toggleOn": "Click to turn on",
|
||||||
"userLabel": "",
|
|
||||||
"usernameHint": "Shown to other participants",
|
"usernameHint": "Shown to other participants",
|
||||||
"usernameLabel": "Your name"
|
"usernameLabel": "Your name"
|
||||||
},
|
},
|
||||||
@@ -108,6 +108,24 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
||||||
},
|
},
|
||||||
|
"rating": {
|
||||||
|
"submit": "Submit",
|
||||||
|
"question": "What do you think about the quality of your call?",
|
||||||
|
"levels": {
|
||||||
|
"min": "very poor",
|
||||||
|
"max": "excellent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openFeedback": {
|
||||||
|
"question": "What can we do to improve Visio?",
|
||||||
|
"placeholder": "Describe your bugs or share your suggestions…",
|
||||||
|
"submit": "Submit",
|
||||||
|
"skip": "Skip"
|
||||||
|
},
|
||||||
|
"confirmationMessage": {
|
||||||
|
"heading": "Thank you for your submission",
|
||||||
|
"body": "Our product team takes the time to carefully review your feedback. We will get back to you as soon as possible."
|
||||||
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "In room",
|
"subheading": "In room",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||||
},
|
},
|
||||||
"feedbackAlert": "Donnez-nous votre avis",
|
"feedback": {
|
||||||
|
"context": "Visio est en pleine construction — votre avis compte !",
|
||||||
|
"cta": "Partagez votre avis"
|
||||||
|
},
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "Accès interdit"
|
"heading": "Accès interdit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"createMeeting": "Créer une réunion",
|
"createMeeting": "Créer une réunion",
|
||||||
"heading": "Visio",
|
"heading": "Visioconférences simples et sécurisées",
|
||||||
"intro": "Collaborez en toute simplicité, où que vous soyez.",
|
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
|
||||||
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
|
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
|
||||||
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
|
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
|
||||||
"joinInputLabel": "Lien complet ou code de la réunion",
|
"joinInputLabel": "Lien complet ou code de la réunion",
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
|
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||||
|
"moreLink": "En savoir plus",
|
||||||
|
"moreAbout": "sur Visio",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
"instantOption": "Démarrer une réunion instantanée"
|
"instantOption": "Démarrer une réunion instantanée"
|
||||||
@@ -20,5 +23,34 @@
|
|||||||
"copy": "Copier le lien de la réunion",
|
"copy": "Copier le lien de la réunion",
|
||||||
"copied": "Lien copié dans le presse-papiers",
|
"copied": "Lien copié dans le presse-papiers",
|
||||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||||
|
},
|
||||||
|
"introSlider": {
|
||||||
|
"previous": {
|
||||||
|
"label": "précédent",
|
||||||
|
"tooltip": "précédent"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"label": "suivant",
|
||||||
|
"tooltip": "suivant"
|
||||||
|
},
|
||||||
|
"beta": {
|
||||||
|
"text": "Essayer la beta",
|
||||||
|
"tooltip": "Accéder au formulaire"
|
||||||
|
},
|
||||||
|
"slide1": {
|
||||||
|
"title": "Essayez Visio pour simplifier votre quotidien",
|
||||||
|
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
|
||||||
|
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
|
||||||
|
},
|
||||||
|
"slide2": {
|
||||||
|
"title": "Organisez des appels de groupe sans limite",
|
||||||
|
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
|
||||||
|
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
|
||||||
|
},
|
||||||
|
"slide3": {
|
||||||
|
"title": "Transformez vos réunions avec l'IA",
|
||||||
|
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
|
||||||
|
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
|
"heading": "Vous avez quitté la réunion",
|
||||||
"heading": "Aidez-nous à améliorer Visio"
|
"home": "Retourner à l'accueil",
|
||||||
|
"back": "Réintégrer la réunion"
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
"joinMeeting": "Rejoindre la réjoindre",
|
"joinMeeting": "Rejoindre la réjoindre",
|
||||||
"toggleOff": "Cliquez pour désactiver",
|
"toggleOff": "Cliquez pour désactiver",
|
||||||
"toggleOn": "Cliquez pour activer",
|
"toggleOn": "Cliquez pour activer",
|
||||||
"userLabel": "",
|
|
||||||
"usernameHint": "Affiché aux autres participants",
|
"usernameHint": "Affiché aux autres participants",
|
||||||
"usernameLabel": "Votre nom"
|
"usernameLabel": "Votre nom"
|
||||||
},
|
},
|
||||||
@@ -108,6 +108,24 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
||||||
},
|
},
|
||||||
|
"rating": {
|
||||||
|
"submit": "Envoyer",
|
||||||
|
"question": "Que pensez-vous de la qualité de votre appel ?",
|
||||||
|
"levels": {
|
||||||
|
"min": "très mauvaise",
|
||||||
|
"max": "excellente"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openFeedback": {
|
||||||
|
"question": "Que pouvons-nous faire pour améliorer Visio ?",
|
||||||
|
"placeholder": "Décrivez vos bugs ou partagez vos suggestions …",
|
||||||
|
"submit": "Envoyer",
|
||||||
|
"skip": "Passer"
|
||||||
|
},
|
||||||
|
"confirmationMessage": {
|
||||||
|
"heading": "Merci pour votre submission",
|
||||||
|
"body": "Notre équipe produit prend le temps d'analyser attentivement vos réponses. Nous reviendrons vers vous dans les plus brefs délais."
|
||||||
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "Dans la réunion",
|
"subheading": "Dans la réunion",
|
||||||
"you": "Vous",
|
"you": "Vous",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const link = cva({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&[data-hovered]': {
|
'&[data-hovered]': {
|
||||||
textDecoration: 'none',
|
textDecorationThickness: '2px',
|
||||||
},
|
},
|
||||||
'&[data-pressed]': {
|
'&[data-pressed]': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export const buttonRecipe = cva({
|
|||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
colorPalette: 'primary',
|
colorPalette: 'primary',
|
||||||
|
'&[data-disabled]': {
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// @TODO: better handling of colors… this is a mess
|
// @TODO: better handling of colors… this is a mess
|
||||||
success: {
|
success: {
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ export { Text } from './Text'
|
|||||||
export { ToggleButton } from './ToggleButton'
|
export { ToggleButton } from './ToggleButton'
|
||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
|
export { TextArea } from './TextArea'
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
replicaCount: 1
|
||||||
|
terminationGracePeriodSeconds: 18000
|
||||||
|
|
||||||
|
egress:
|
||||||
|
log_level: debug
|
||||||
|
ws_url: ws://livekit-livekit-server:80
|
||||||
|
insecure: true
|
||||||
|
enable_chrome_sandbox: true
|
||||||
|
{{- with .Values.livekit.keys }}
|
||||||
|
{{- range $key, $value := . }}
|
||||||
|
api_key: {{ $key }}
|
||||||
|
api_secret: {{ $value }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
redis:
|
||||||
|
address: redis-master:6379
|
||||||
|
password: pass
|
||||||
|
s3:
|
||||||
|
access_key: meet
|
||||||
|
secret: password
|
||||||
|
region: local
|
||||||
|
bucket: meet-media-storage
|
||||||
|
endpoint: http://minio:9000
|
||||||
|
force_path_style: true
|
||||||
|
|
||||||
|
loadBalancer:
|
||||||
|
type: nginx
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- livekit-egress.127.0.0.1.nip.io
|
||||||
|
secretName: livekit-egress-dinum-cert
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 5
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
resources: {}
|
||||||
@@ -4,13 +4,20 @@ terminationGracePeriodSeconds: 18000
|
|||||||
livekit:
|
livekit:
|
||||||
log_level: debug
|
log_level: debug
|
||||||
rtc:
|
rtc:
|
||||||
use_external_ip: true
|
use_external_ip: false
|
||||||
port_range_start: 50000
|
port_range_start: 50000
|
||||||
port_range_end: 60000
|
port_range_end: 60000
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
redis:
|
redis:
|
||||||
address:
|
address: redis-master:6379
|
||||||
|
password: pass
|
||||||
keys:
|
keys:
|
||||||
|
turn:
|
||||||
|
enabled: true
|
||||||
|
udp_port: 443
|
||||||
|
domain: livekit.127.0.0.1.nip.io
|
||||||
|
loadBalancerAnnotations: {}
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
type: nginx
|
type: nginx
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ backend:
|
|||||||
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
|
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
|
||||||
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email given_name usual_name"
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
@@ -47,10 +47,13 @@ backend:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||||
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
|
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.7"
|
tag: "v0.1.8"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -42,7 +42,7 @@ backend:
|
|||||||
name: backend
|
name: backend
|
||||||
key: OIDC_RP_CLIENT_SECRET
|
key: OIDC_RP_CLIENT_SECRET
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email given_name usual_name"
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr
|
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr
|
||||||
@@ -94,11 +94,27 @@ backend:
|
|||||||
name: backend
|
name: backend
|
||||||
key: LIVEKIT_API_KEY
|
key: LIVEKIT_API_KEY
|
||||||
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
|
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
|
||||||
ANALYTICS_KEY: mwuxTKi8o2xzWH54
|
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
|
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
|
AWS_S3_ENDPOINT_URL:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: url
|
||||||
|
AWS_S3_ACCESS_KEY_ID:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: accessKey
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: secretKey
|
||||||
|
AWS_STORAGE_BUCKET_NAME:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: bucket
|
||||||
|
AWS_S3_REGION_NAME: local
|
||||||
|
|
||||||
createsuperuser:
|
createsuperuser:
|
||||||
command:
|
command:
|
||||||
@@ -112,7 +128,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.7"
|
tag: "v0.1.8"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ backend:
|
|||||||
name: backend
|
name: backend
|
||||||
key: OIDC_RP_CLIENT_SECRET
|
key: OIDC_RP_CLIENT_SECRET
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email given_name usual_name"
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr
|
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr
|
||||||
@@ -93,10 +93,26 @@ backend:
|
|||||||
name: backend
|
name: backend
|
||||||
key: LIVEKIT_API_KEY
|
key: LIVEKIT_API_KEY
|
||||||
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
|
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
|
||||||
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
|
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
|
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
|
AWS_S3_ENDPOINT_URL:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: url
|
||||||
|
AWS_S3_ACCESS_KEY_ID:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: accessKey
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: secretKey
|
||||||
|
AWS_STORAGE_BUCKET_NAME:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: bucket
|
||||||
|
AWS_S3_REGION_NAME: local
|
||||||
|
|
||||||
createsuperuser:
|
createsuperuser:
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: core.libre.sh/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: meet-media-storage
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
spec:
|
||||||
|
provider: data
|
||||||
|
versioned: true
|
||||||
@@ -45,6 +45,28 @@ releases:
|
|||||||
enabled: true
|
enabled: true
|
||||||
autoGenerated: true
|
autoGenerated: true
|
||||||
|
|
||||||
|
- name: minio
|
||||||
|
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
||||||
|
namespace: {{ .Namespace }}
|
||||||
|
chart: bitnami/minio
|
||||||
|
version: 12.10.10
|
||||||
|
values:
|
||||||
|
- auth:
|
||||||
|
rootUser: meet
|
||||||
|
rootPassword: password
|
||||||
|
- provisioning:
|
||||||
|
enabled: true
|
||||||
|
buckets:
|
||||||
|
- name: meet-media-storage
|
||||||
|
versioning: true
|
||||||
|
- ingress:
|
||||||
|
enabled: true
|
||||||
|
hostname: minio-console.127.0.0.1.nip.io
|
||||||
|
servicePort: 9001
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
|
||||||
- name: redis
|
- name: redis
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
@@ -85,3 +107,12 @@ releases:
|
|||||||
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
|
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
|
||||||
secrets:
|
secrets:
|
||||||
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
||||||
|
|
||||||
|
- name: livekit-egress
|
||||||
|
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
||||||
|
namespace: {{ .Namespace }}
|
||||||
|
chart: livekit/egress
|
||||||
|
values:
|
||||||
|
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
|
||||||
|
secrets:
|
||||||
|
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.5.4",
|
"@html-to/text-cli": "0.5.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user