Compare commits

..

8 Commits

Author SHA1 Message Date
lebaudantoine aa96d04b5e wip status while recording 2024-09-13 13:18:07 +02:00
lebaudantoine 5321ade8a5 wip send email with link to doc 2024-09-13 10:14:05 +02:00
lebaudantoine 5d7f9e1325 🚧(backend) poc email all owners of a room
Dirty code, missing proper exceptions catching,
permissions etc.

Try to deploy it on staging, see if email are sent.
2024-09-12 15:25:43 +02:00
lebaudantoine 94c19d026d wip update tag 2024-09-12 11:21:44 +02:00
lebaudantoine 3da3641a19 🚧 (frontend) Add start/stop audio recording menu item
Functional but not mergeable—remote participants need to be informed
when recording starts, including the egress ID to prevent collisions.

Quick and dirty implementation.
2024-09-12 10:58:21 +02:00
lebaudantoine 21626b4c4c 🚧(frontend) show recording status indicator
Room recording status triggers only when the egress worker is active.
Querying the Egress API starts the worker, which joins the room as an invisible participant.
The room is marked as "recording" only once the worker has fully joined.
Not mergeable yet.

Sadly the room event gives no information about the Egress worker’s ID.
2024-09-12 10:58:19 +02:00
lebaudantoine f16cedf5a5 🚧(frontend) draft audio-only room recording method
(roomId is wrongly named, and designates room’s slug)

Room’s slug are passed in filenames to simplify post-processing. Thus, from filename,
we can retrieve the room being recorded.

Current limitation: audio tracks are mixed, requiring diarization.
Future plans include full audio + video recording for a more complete meeting experience.
2024-09-12 10:49:00 +02:00
lebaudantoine 00e4679dc5 🚧(backend) allow room recording
Added 'room_record' grant to LiveKit token to enable room recording.
Initial implementation allows any authenticated user to record,
but this needs refinement.

Only admins and persisted room members should have this capability,
as they are the ones notified post-recording. Not ready for merge.
2024-09-12 10:49:00 +02:00
250 changed files with 5067 additions and 20081 deletions
+2 -62
View File
@@ -1,5 +1,4 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -49,15 +48,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
target: backend-production
@@ -99,15 +92,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
@@ -117,57 +104,10 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-summary
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: |
github.event_name != 'pull_request'
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "meet,secrets"
-2
View File
@@ -8,5 +8,3 @@ creation_rules:
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+31 -22
View File
@@ -1,17 +1,18 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.10-slim-bullseye as base
# 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
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
# ---- Back-end builder image ----
FROM base AS back-builder
FROM base as back-builder
WORKDIR /builder
@@ -23,7 +24,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
@@ -34,12 +35,15 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base AS link-collector
FROM base as link-collector
ARG MEET_STATIC_ROOT=/data/static
RUN apk add \
pango \
rdfind
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -58,18 +62,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
# ---- Core application image ----
FROM base AS core
FROM base as core
ENV PYTHONUNBUFFERED=1
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -93,13 +100,15 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core AS backend-development
FROM core as backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apk add postgresql-client
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
@@ -119,7 +128,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core AS backend-production
FROM core as backend-production
ARG MEET_STATIC_ROOT=/data/static
+1 -11
View File
@@ -1,6 +1,7 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
docker_build(
'localhost:5001/meet-backend:latest',
context='..',
@@ -27,17 +28,6 @@ docker_build(
]
)
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
sync('../src/summary', '/home/summary'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
migration = '''
+1 -1
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="meet"
+6 -42
View File
@@ -29,7 +29,7 @@ echo "2. Create kind cluster with containerd registry config dir enabled"
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --name visio --config=-
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
@@ -52,6 +52,10 @@ nodes:
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
echo "3. Add the registry config to the nodes"
@@ -64,7 +68,7 @@ echo "3. Add the registry config to the nodes"
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes --name visio); do
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
@@ -93,47 +97,7 @@ data:
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
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"
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 patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
echo "7. Setup namespace"
kubectl create ns meet
kubectl config set-context --current --namespace=meet
kubectl -n meet create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+1
View File
@@ -1,3 +1,4 @@
version: '3.8'
services:
postgresql:
+1
View File
@@ -42,3 +42,4 @@ LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
WORKER_SECRET=DevPurposeOnly
+1 -3
View File
@@ -13,9 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"eslint"
]
"matchPackageNames": []
}
]
}
+1 -1
Submodule secrets updated: f5aea0c251...1a016aca31
+2 -2
View File
@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=20
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=10
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
+4 -48
View File
@@ -22,19 +22,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -64,8 +52,6 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"admin_email",
"email",
"full_name",
"short_name",
"is_active",
"is_staff",
"is_superuser",
@@ -74,24 +60,9 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = (
"is_active",
"-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")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
class ResourceAccessInline(admin.TabularInline):
@@ -106,18 +77,3 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
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")
+58
View File
@@ -0,0 +1,58 @@
"""
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()
-4
View File
@@ -37,10 +37,6 @@ def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+5 -41
View File
@@ -1,7 +1,5 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -67,11 +65,15 @@ class RoomPermissions(permissions.BasePermission):
return obj.is_administrator(user)
class ResourceAccessPermission(IsAuthenticated):
class ResourceAccessPermission(permissions.BasePermission):
"""
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):
"""
Check that the logged-in user is administrator of the linked room.
@@ -81,41 +83,3 @@ class ResourceAccessPermission(IsAuthenticated):
return obj.user == 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
+4 -45
View File
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email"]
read_only_fields = ["id", "email"]
class ResourceAccessSerializerMixin:
@@ -87,15 +87,6 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
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):
"""Serialize Room model for the API."""
@@ -130,7 +121,8 @@ class RoomSerializer(serializers.ModelSerializer):
del output["configuration"]
if role is not None or instance.is_public:
slug = f"{instance.id!s}"
slug = f"{instance.id!s}".replace("-", "")
username = request.query_params.get("username", None)
output["livekit"] = {
@@ -144,36 +136,3 @@ class RoomSerializer(serializers.ModelSerializer):
output["is_administrable"] = is_admin
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")
+42 -176
View File
@@ -1,7 +1,8 @@
"""API endpoints"""
import smtplib
import uuid
from logging import getLogger
import requests
from django.conf import settings
from django.db.models import Q
@@ -16,41 +17,19 @@ from rest_framework import (
viewsets,
)
from rest_framework import (
exceptions as drf_exceptions,
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
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.notification import notification_service
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
# pylint: disable=too-many-ancestors
logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -212,6 +191,13 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
@@ -260,88 +246,46 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-recording",
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,
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
)
@decorators.action(
methods=["POST"],
detail=True,
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
url_path="summary",
permission_classes=[drf_permissions.AllowAny],
)
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
def summary(self, request, pk=None): # pylint: disable=unused-argument
"""Wip"""
room = self.get_object()
secret = self.request.data.get("secret", None)
if secret is None or secret != settings.WORKER_SECRET:
return drf_response.Response({"message": "Invalid secret"}, status=403)
summary = self.request.data.get("summary", None)
transcript = self.request.data.get("transcript", None)
instance = self.get_object()
owners = instance.get_owners()
email = owners[0].email
url = f"https://dinum-pad-dev.osc-fr1.scalingo.io/summary?email={email}"
headers = {
"Content-Type": "text/plain"
}
response = requests.post(url, headers=headers, data=summary, allow_redirects=False)
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
instance.email_summary(summary=summary, transcript=transcript, owners=owners, link=response.headers['Location'])
except smtplib.SMTPException:
return drf_response.Response({"message": "Error"}, status=500)
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}."}
)
return drf_response.Response({"message": "Webhook data received"}, status=200)
class ResourceAccessListModelMixin:
@@ -387,81 +331,3 @@ class ResourceAccessViewSet(
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.ResourceAccess.objects.all()
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."
)
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
return drf_response.Response(
{"message": "Event processed."},
)
+24 -63
View File
@@ -1,6 +1,5 @@
"""Authentication Backends for the Meet core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -11,6 +10,8 @@ from mozilla_django_oidc.auth import (
from core.models import User
from ..analytics import analytics
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -67,76 +68,36 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
password="!", # noqa: S106
**claims,
)
elif not user:
return None
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
analytics.identify(user=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
def create_user(self, claims):
"""Return a newly created User instance."""
@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
),
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
)
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)
return user
+8 -2
View File
@@ -22,6 +22,8 @@ from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
from ..analytics import analytics
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
@@ -98,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
logout_url = self.redirect_url
analytics.track(
user=request.user,
event="Signed Out",
)
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
@@ -145,7 +151,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
"""Custom callback view for handling the silent loging flow."""
@property
def failure_url(self):
@@ -162,7 +168,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
"""Custom authentication view for handling the silent loging flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
-54
View File
@@ -23,8 +23,6 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
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])
password = make_password("password")
@@ -34,7 +32,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Resource
skip_postgeneration_save = True
is_public = factory.Faker("boolean", chance_of_getting_true=50)
@@ -48,8 +45,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing."""
@@ -70,52 +65,3 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
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)
@@ -1,67 +0,0 @@
# 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.'),
),
]
@@ -1,89 +0,0 @@
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),
]
@@ -1,18 +0,0 @@
# 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'),
),
]
@@ -1,23 +0,0 @@
# 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'),
)
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2024-12-02 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_user_full_name_user_short_name'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=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'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
),
]
+53 -282
View File
@@ -4,7 +4,6 @@ Declare and configure the models for the Meet core application
import uuid
from logging import getLogger
from typing import List
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,10 +11,14 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.template.loader import render_to_string
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from django.core.mail import EmailMessage
from django.core.files.base import ContentFile
from timezone_field import TimeZoneField
logger = getLogger(__name__)
@@ -39,47 +42,6 @@ class RoleChoices(models.TextChoices):
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")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@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):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -142,14 +104,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
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
# stores the email used by staff users to log in to the admin site
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("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(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
@@ -195,11 +154,28 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def __str__(self):
return self.email or self.admin_email or str(self.id)
def email_user(self, subject, message, from_email=None, **kwargs):
def email_user(self, subject, transcript, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
# mail.send_mail(subject, message, from_email, [self.email], **kwargs)
email = EmailMessage(
subject,
kwargs['html_message'],
from_email,
[self.email],
)
# email.attach_alternative(kwargs['html_message'], "text/html")
transcript = transcript or ''
wip_file = ContentFile(transcript, 'transcript.txt')
email.content_subtype = "html"
email.attach(wip_file.name, wip_file.read(), 'text/plain')
email.send()
def get_teams(self):
"""
@@ -208,39 +184,18 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""
return []
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
"""
Get all roles assigned to a user for a specific resource, including team-based roles.
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 []
@property
def email_anonymized(self):
"""Anonymize the email address by replacing the local part with asterisks."""
if not self.email:
return ""
return f"***@{self.email.split('@')[1]}"
class Resource(BaseModel):
"""Model to define access control"""
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
users = models.ManyToManyField(
User,
through="ResourceAccess",
@@ -392,213 +347,29 @@ class Room(Resource):
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
def get_owners(self):
"""Fetch the user who is the owner of the room."""
owner_accesses = self.accesses.filter(role=RoleChoices.OWNER)
return [access.user for access in owner_accesses]
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
def email_summary(self, owners, summary, transcript, link):
"""Wip"""
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()))
template_vars = {
"title": _("A new summary is ready"),
"room": self.slug,
"summary": summary,
"link": link,
}
msg_plain = render_to_string("mail/text/summary.txt", template_vars)
msg_html = render_to_string("mail/html/summary.html", template_vars)
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
for owner in owners:
owner.email_user(
subject=_("A new summary is ready"),
transcript=transcript,
from_email=settings.EMAIL_FROM,
html_message=msg_html,
fail_silently=False,
)
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.
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
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
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.
"""
room = models.ForeignKey(
Room,
on_delete=models.CASCADE,
related_name="recordings",
verbose_name=_("Room"),
)
status = models.CharField(
max_length=50,
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 = 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)
@@ -1 +0,0 @@
"""Meet event parser classes, authentication and exceptions."""
@@ -1,94 +0,0 @@
"""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
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'"
@@ -1,17 +0,0 @@
"""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."""
@@ -1,93 +0,0 @@
"""Service to notify external services when a new recording is ready."""
import logging
from django.conf import settings
import requests
from core import models
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for processing recordings and notifying external services."""
def notify_external_services(self, recording):
"""Process a recording based on its mode."""
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
logger.error(
"Unknown recording mode %s for recording %s",
recording.mode,
recording.id,
)
return False
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
if (
not settings.SUMMARY_SERVICE_ENDPOINT
or not settings.SUMMARY_SERVICE_API_TOKEN
):
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
}
try:
response = requests.post(
settings.SUMMARY_SERVICE_ENDPOINT,
json=payload,
headers=headers,
timeout=30,
)
response.raise_for_status()
except requests.HTTPError as exc:
logger.exception(
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
)
return False
return True
notification_service = NotificationService()
-147
View File
@@ -1,147 +0,0 @@
"""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
@@ -1 +0,0 @@
"""Meet worker services classes and exceptions."""
@@ -1,21 +0,0 @@
"""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."""
@@ -1,75 +0,0 @@
"""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)
@@ -1,98 +0,0 @@
"""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)
@@ -1,140 +0,0 @@
"""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,35 +11,32 @@ from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_authentication_getter_existing_user(monkeypatch):
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches, the user should be returned.
If an existing user matches the user's info sub, the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": db_user.sub, "email": "some@mail.com"}
def get_existing_user(*args):
return db_user
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
monkeypatch.setattr(
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches, a user should be created.
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
"""
klass = OIDCAuthenticationBackend()
@@ -61,15 +58,16 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches, a user should be created.
User's info contains an email, created user's email should be filled.
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
"""
klass = OIDCAuthenticationBackend()
email = "meet@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -79,34 +77,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
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 models.User.objects.count() == 1
@@ -132,283 +102,3 @@ 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)
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@xample.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"
@@ -1,145 +0,0 @@
"""
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)
@@ -1,310 +0,0 @@
"""
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
@@ -1,114 +0,0 @@
"""
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
@@ -1,180 +0,0 @@
"""
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"])
@@ -1,203 +0,0 @@
"""
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
@@ -1,171 +0,0 @@
"""
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)
@@ -1,161 +0,0 @@
"""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
@@ -1,368 +0,0 @@
"""
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():
"""It should be possible to get a room by its id stripped of its dashes."""
room = RoomFactory(is_public=False)
id_no_dashes = str(room.id)
id_no_dashes = str(room.id).replace("-", "")
client = APIClient()
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}/")
assert response.status_code == 200
expected_name = f"{room.id!s}"
expected_name = f"{room.id!s}".replace("-", "")
assert response.json() == {
"id": str(room.id),
"is_administrable": False,
@@ -220,7 +220,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
)
assert response.status_code == 200
expected_name = f"{room.id!s}"
expected_name = f"{room.id!s}".replace("-", "")
assert response.json() == {
"id": str(room.id),
"is_administrable": False,
@@ -301,8 +301,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
@@ -312,8 +310,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -322,7 +318,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
key=lambda x: x["id"],
)
expected_name = str(room.id)
expected_name = str(room.id).replace("-", "")
assert content_dict == {
"id": str(room.id),
"is_administrable": False,
@@ -378,8 +374,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -389,8 +383,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
@@ -398,7 +390,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
],
key=lambda x: x["id"],
)
expected_name = str(room.id)
expected_name = str(room.id).replace("-", "")
assert content_dict == {
"id": str(room.id),
"is_administrable": True,
@@ -1,200 +0,0 @@
"""
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"
@@ -1,182 +0,0 @@
"""
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
+132
View File
@@ -0,0 +1,132 @@
"""
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()
-2
View File
@@ -96,8 +96,6 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
@@ -1,218 +0,0 @@
"""
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)
@@ -1,312 +0,0 @@
"""
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,3 +44,12 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
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 == ""
+1 -3
View File
@@ -5,14 +5,13 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
@@ -24,7 +23,6 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
+1
View File
@@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
room=room,
room_join=True,
room_admin=True,
room_record=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
+10 -97
View File
@@ -119,25 +119,6 @@ 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
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -270,19 +251,6 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
# Frontend
FRONTEND_CONFIGURATION = {
"analytics": values.DictValue(
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
),
"support": values.DictValue(
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
),
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
}
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
@@ -324,14 +292,8 @@ class Base(Configuration):
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
@@ -392,16 +354,6 @@ class Base(Configuration):
OIDC_REDIRECT_FIELD_NAME = values.Value(
"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
LIVEKIT_CONFIGURATION = {
@@ -411,50 +363,17 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue(
True, environ_name="DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None
)
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_ENABLE", environ_prefix=None
ANALYTICS_KEY = values.Value(
None, environ_name="ANALYTICS_KEY", 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_EVENT_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = values.Value(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
WORKER_SECRET = values.Value(
None, environ_name="WORKER_SECRET", environ_prefix=None
)
# pylint: disable=invalid-name
@@ -576,6 +495,8 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
ANALYTICS_KEY = None
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
@@ -618,14 +539,6 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
+22 -23
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.10"
version = "0.1.5"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,39 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.68",
"boto3==1.35.10",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.2.0",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.3",
"django==5.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.19.0",
"sentry-sdk==2.13.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.0",
"aiohttp==3.11.7",
"livekit-api==0.7.0",
]
[project.urls]
@@ -69,21 +69,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.11.1",
"freezegun==1.5.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.29.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.0",
"types-requests==2.32.0.20241016",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
+5 -5
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-deps
FROM node:20-alpine as frontend-deps
WORKDIR /home/frontend/
@@ -11,11 +11,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS meet
FROM frontend-deps as meet
WORKDIR /home/frontend
FROM frontend-deps AS meet-dev
FROM frontend-deps as meet-dev
WORKDIR /home/frontend
@@ -25,14 +25,14 @@ CMD [ "npm", "run", "dev"]
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
# to avoid rebuilding the app at every changes.
FROM meet AS meet-builder
FROM meet as meet-builder
WORKDIR /home/frontend
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en" data-lk-theme="visio-light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visio</title>
<title>Meet</title>
</head>
<body>
<div id="root"></div>
+3180 -2805
View File
File diff suppressed because it is too large Load Diff
+32 -35
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.10",
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,45 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.6.9",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.48.0",
"@react-aria/toast": "3.0.0-beta.18",
"@remixicon/react": "4.5.0",
"@tanstack/react-query": "5.61.3",
"crisp-sdk-web": "1.0.25",
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@react-aria/toast": "3.0.0-beta.15",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
"i18next": "24.0.2",
"i18next": "23.12.1",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2",
"i18next-parser": "9.0.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.6.3",
"posthog-js": "1.188.0",
"react": "18.3.1",
"react-aria-components": "1.5.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"livekit-client": "2.3.1",
"react": "18.2.0",
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {
"@pandacss/dev": "0.48.0",
"@tanstack/eslint-plugin-query": "5.61.3",
"@tanstack/react-query-devtools": "5.61.3",
"@types/node": "22.9.3",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.15.0",
"@typescript-eslint/parser": "8.15.0",
"@vitejs/plugin-react": "4.3.3",
"@pandacss/dev": "0.41.0",
"@tanstack/eslint-plugin-query": "5.49.1",
"@tanstack/react-query-devtools": "5.49.2",
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-refresh": "0.4.14",
"postcss": "8.4.49",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.7.2",
"vite": "5.4.11",
"vite-tsconfig-paths": "5.1.3"
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
}
}
+14 -109
View File
@@ -35,46 +35,11 @@ const config: Config = {
exclude: [],
jsxFramework: 'react',
outdir: 'src/styled-system',
globalFontface: {
Marianne: [
{
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'italic',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
fontWeight: 500,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
fontWeight: 700,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
fontWeight: 800,
fontStyle: 'normal',
fontDisplay: 'swap',
},
],
},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
breakpoints: {
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
xsm: '31.25em', // 500px,
sm: '40em', // 640px
md: '48em', // 768px
lg: '64em', // 1024px
@@ -99,16 +64,16 @@ const config: Config = {
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
},
active_speaker: {
'0%': { height: '25%' },
'25%': { height: '45%' },
'50%': { height: '20%' },
'100%': { height: '55%' },
'0%': { height: '4px' },
'25%': { height: '8px' },
'50%': { height: '6px' },
'100%': { height: '16px' },
},
active_speaker_small: {
'0%': { height: '20%' },
'25%': { height: '25%' },
'50%': { height: '18%' },
'100%': { height: '25%' },
active_speake_small: {
'0%': { height: '4px' },
'25%': { height: '6px' },
'50%': { height: '4px' },
'100%': { height: '8px' },
},
wave_hand: {
'0%': { transform: 'rotate(0deg)' },
@@ -116,10 +81,10 @@ const config: Config = {
'80%': { transform: 'rotate(20deg)' },
'100%': { transform: 'rotate(0)' },
},
pulse_background: {
'0%': { opacity: '1' },
'50%': { opacity: '0.65' },
'100%': { opacity: '1' },
pulse_mic: {
'0%': { color: 'primary', opacity: '1' },
'50%': { color: 'primary', opacity: '0.8' },
'100%': { color: 'primary', opacity: '1' },
},
},
tokens: defineTokens({
@@ -127,66 +92,6 @@ const config: Config = {
* This way we'll only add the things we need step by step and prevent using lots of differents things.
*/
...pandaPreset.theme.tokens,
colors: defineTokens.colors({
...pandaPreset.theme.tokens.colors,
primaryDark: {
50: { value: '#161622' },
100: { value: '#2D2D46' },
200: { value: '#43436A' },
300: { value: '#5A5A8F' },
400: { value: '#7070B3' },
500: { value: '#8787D7' },
600: { value: '#9D9DDF' },
700: { value: '#B3B3E7' },
800: { value: '#C9C9EE' },
900: { value: '#DFDFF6' },
950: { value: '#F5F5FE' },
action: { value: '#C1C1FB' },
},
primary: {
50: { value: '#F5F5FE' },
100: { value: '#ECECFE' },
200: { value: '#E3E3FB' },
300: { value: '#CACAFB' },
400: { value: '#8585F6' },
500: { value: '#6A6AF4' },
600: { value: '#313178' },
700: { value: '#272747' },
800: { value: '#000091' },
900: { value: '#21213F' },
950: { value: '#1B1B35' },
action: { value: '#1212FF' },
},
greyscale: {
'000': { value: '#FFFFFF' },
50: { value: '#F6F6F6' },
100: { value: '#EEEEEE' },
200: { value: '#E5E5E5' },
250: { value: '#DDDDDD' },
300: { value: '#CECECE' },
400: { value: '#929292' },
500: { value: '#7C7C7C' },
600: { value: '#666666' },
700: { value: '#3A3A3A' },
750: { value: '#353535' },
800: { value: '#2A2A2A' },
900: { value: '#242424' },
950: { value: '#1E1E1E' },
1000: { value: '#161616' },
},
error: {
100: { value: '#261212' },
200: { value: '#6C302E' },
300: { value: '#983533' },
400: { value: '#CA3632' },
500: { value: '#EF413D' },
600: { value: '#EE6A66' },
700: { value: '#F28D8A' },
800: { value: '#F6AFAD' },
900: { value: '#FAD2D1' },
950: { value: '#FFF4F4' },
},
}),
animations: {},
blurs: {},
/* just directly use values as tokens. This allows us to follow a specific design scale,
@@ -288,7 +193,7 @@ const config: Config = {
semanticTokens: defineSemanticTokens({
colors: {
default: {
text: { value: '{colors.greyscale.1000}' },
text: { value: '{colors.gray.900}' },
bg: { value: 'white' },
subtle: { value: '{colors.gray.100}' },
'subtle-text': { value: '{colors.gray.600}' },
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 4.00098V5.33431H3.33333V12.6676H10.6667V9.33431H12V13.3343C12 13.7025 11.7015 14.001 11.3333 14.001H2.66667C2.29848 14.001 2 13.7025 2 13.3343V4.66764C2 4.29945 2.29848 4.00098 2.66667 4.00098H6.66667ZM14 2.00098V7.33431H12.6667V4.27631L7.47133 9.47231L6.52867 8.52964L11.7233 3.33431H8.66667V2.00098H14Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 484 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.
+6 -3
View File
@@ -11,15 +11,18 @@ import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
@@ -30,7 +33,7 @@ function App() {
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
<ReactQueryDevtools initialIsOpen={false} />
</I18nProvider>
</Suspense>
</QueryClientProvider>
-1
View File
@@ -1,5 +1,4 @@
export const keys = {
user: 'user',
room: 'room',
config: 'config',
}
-31
View File
@@ -1,31 +0,0 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/rooms/api/startRecording'
export interface ApiConfig {
analytics?: {
id: string
host: string
}
support?: {
id: string
}
silence_livekit_debug_logs?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
}
}
const fetchConfig = (): Promise<ApiConfig> => {
return fetchApi<ApiConfig>(`config/`)
}
export const useConfig = () => {
return useQuery({
queryKey: [keys.config],
queryFn: fetchConfig,
staleTime: Infinity,
})
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="448" height="172" viewBox="0 0 448 172" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61135 65.6951C5 68.832 5 72.6949 5 79.1656V94.7307C5 102.759 5 106.773 6.16756 110.366C7.20062 113.546 8.89041 116.472 11.1274 118.957C13.6555 121.765 17.1318 123.772 24.0842 127.786L37.564 135.568C43.7169 139.121 47.1472 141.101 50.4165 142.054V94.0288C50.4165 91.6281 49.1052 89.4192 46.9976 88.2696L5.61135 65.6951Z" fill="#C9191E"/>
<path d="M118.313 72.7489V97.7898C118.313 101.252 119.818 104.544 122.436 106.809L140.459 122.406C141.513 123.298 142.608 124.028 143.744 124.596C144.92 125.123 146.056 125.387 147.151 125.387C149.503 125.387 151.389 124.616 152.809 123.075C154.269 121.493 154.999 119.445 154.999 116.93V53.6577C154.999 51.1431 154.269 49.1151 152.809 47.5738C151.389 45.992 149.503 45.2011 147.151 45.2011C146.056 45.2011 144.92 45.4647 143.744 45.992C142.608 46.5193 141.513 47.2494 140.459 48.1822L122.45 63.7172C119.823 65.983 118.313 69.28 118.313 72.7489Z" fill="#000091"/>
<path d="M11.6345 56.7522C11.1333 56.4788 10.6078 56.2937 10.0757 56.1915C10.4114 55.7628 10.7622 55.3452 11.1276 54.9394C13.6558 52.1315 17.132 50.1245 24.0845 46.1105L37.5643 38.3279C44.5167 34.3139 47.993 32.3069 51.6887 31.5213C54.9587 30.8262 58.3383 30.8262 61.6083 31.5213C65.304 32.3069 68.7803 34.3139 75.7327 38.3279L89.0535 46.0187C89.1066 46.0492 89.1597 46.0798 89.2129 46.1105C96.1653 50.1245 99.6416 52.1316 102.17 54.9394C104.407 57.4238 106.096 60.3506 107.13 63.5301C108.297 67.1235 108.297 71.1375 108.297 79.1656V94.7307C108.297 102.759 108.297 106.773 107.13 110.366C106.096 113.546 104.407 116.473 102.17 118.957C99.6416 121.765 96.1655 123.772 89.2133 127.785C89.1537 127.82 89.0936 127.855 89.0341 127.889L75.7327 135.568C68.7803 139.582 65.304 141.589 61.6083 142.375C61.4564 142.407 61.3043 142.438 61.1519 142.467L61.1519 94.0288C61.1519 87.6997 57.6948 81.8761 52.1386 78.8455L11.6345 56.7522Z" fill="#000091"/>
<path d="M193.72 45.7333H211.035L234.771 108.456L258.507 45.7333H275.821L245.435 126H224.107L193.72 45.7333ZM289.019 58.1173C283.859 58.1173 279.501 53.76 279.501 48.6C279.501 43.44 283.859 39.0827 289.019 39.0827C294.179 39.0827 298.421 43.44 298.421 48.6C298.421 53.76 294.179 58.1173 289.019 58.1173ZM281.68 126V68.208H296.243V126H281.68ZM303.29 117.629L312.922 108.915C316.477 113.387 320.72 116.597 326.224 116.597C330.925 116.597 333.333 113.845 333.333 110.405C333.333 100.315 305.698 104.099 305.698 83.8027C305.698 73.5973 314.298 65.9147 326.338 65.9147C335.168 65.9147 343.194 70.1573 347.322 75.776L337.69 84.2613C334.709 80.592 330.925 77.6107 326.453 77.6107C321.866 77.6107 319.688 80.1333 319.688 83.1147C319.688 92.976 347.322 89.536 347.322 109.488C347.093 121.643 337.346 128.293 326.453 128.293C316.133 128.293 308.794 124.165 303.29 117.629ZM363.494 58.1173C358.334 58.1173 353.977 53.76 353.977 48.6C353.977 43.44 358.334 39.0827 363.494 39.0827C368.654 39.0827 372.897 43.44 372.897 48.6C372.897 53.76 368.654 58.1173 363.494 58.1173ZM356.155 126V68.208H370.718V126H356.155ZM411.115 65.9147C429.92 65.9147 442.763 79.7893 442.763 97.104C442.763 114.419 429.92 128.293 411.115 128.293C392.309 128.293 379.467 114.419 379.467 97.104C379.467 79.7893 392.309 65.9147 411.115 65.9147ZM411.344 114.533C420.632 114.533 427.627 107.08 427.627 97.104C427.627 87.0133 420.632 79.6747 411.344 79.6747C401.712 79.6747 394.603 87.0133 394.603 97.104C394.603 107.195 401.712 114.533 411.344 114.533Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,20 +0,0 @@
import { silenceLiveKitLogs } from '@/utils/livekit'
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
export const AppInitialization = () => {
const { data } = useConfig()
const {
analytics = {},
support = {},
silence_livekit_debug_logs = false,
} = data || {}
useAnalytics(analytics)
useSupport(support)
silenceLiveKitLogs(silence_livekit_debug_logs)
return null
}
+25
View File
@@ -0,0 +1,25 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export const Feedback = () => {
const { t } = useTranslation()
return (
<Button
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"
/>
</Button>
)
}
@@ -1,46 +0,0 @@
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>
)
}
@@ -7,7 +7,6 @@ import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
delay = 500,
header = undefined,
footer = undefined,
layout = 'centered',
}: {
delay?: number
@@ -16,7 +15,7 @@ export const LoadingScreen = ({
return (
<DelayedRender delay={delay}>
<Screen layout={layout} header={header} footer={footer}>
<Screen layout={layout} header={header}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -21,7 +21,7 @@ export const QueryAware = ({
}
if (status === 'pending') {
return <LoadingScreen header={undefined} footer={undefined} />
return <LoadingScreen header={undefined} />
}
return children
+1 -1
View File
@@ -27,7 +27,7 @@ export const SoundTester = () => {
return (
<>
<Button
variant="secondaryText"
invisible
onPress={() => {
audioRef?.current?.play()
setIsPlaying(true)
@@ -1,39 +0,0 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import posthog from 'posthog-js'
import { ApiUser } from '@/features/auth/api/ApiUser'
export const startAnalyticsSession = (data: ApiUser) => {
if (posthog._isIdentified()) return
const { id, email } = data
posthog.identify(id, { email })
}
export const terminateAnalyticsSession = () => {
if (!posthog._isIdentified()) return
posthog.reset()
}
export type useAnalyticsProps = {
id?: string
host?: string
}
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
const [location] = useLocation()
useEffect(() => {
if (!id || !host) return
if (posthog.__loaded) return
posthog.init(id, {
api_host: host,
person_profiles: 'always',
})
}, [id, host])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
posthog.capture('$pageview')
}, [location])
return null
}
@@ -1,6 +0,0 @@
import { useConfig } from '@/api/useConfig.ts'
export const useIsAnalyticsEnabled = () => {
const { data } = useConfig()
return !!data?.analytics?.id
}

Some files were not shown because too many files have changed in this diff Show More