Compare commits

..

30 Commits

Author SHA1 Message Date
lebaudantoine 808fc7a00f wip update prompt 2024-10-18 10:00:38 +02:00
lebaudantoine c8b4592d88 wip iterate on the prompt 2024-10-17 16:54:09 +02:00
lebaudantoine db071f0dcc wip iterate on the prompt 2024-10-17 14:04:38 +02:00
lebaudantoine b1b28fe330 iterate on prompt 2024-10-17 13:40:31 +02:00
lebaudantoine 98950f43af fix logger 2024-10-17 12:08:37 +02:00
lebaudantoine b43f9922b6 wip enable openai 2024-10-17 11:56:47 +02:00
lebaudantoine d49dc72849 fix endpoint 2024-10-17 11:55:08 +02:00
lebaudantoine 82f0251101 fix code 2024-10-17 11:15:25 +02:00
lebaudantoine 2f675c2a3f wip clean and get blocknote content 2024-10-17 10:55:05 +02:00
lebaudantoine 7105c7cbae remove tl 2024-10-16 18:04:44 +02:00
lebaudantoine 376ff8982c wip room_slug 2024-10-16 17:57:58 +02:00
lebaudantoine a4820ae867 wip email user 2024-10-16 17:47:32 +02:00
lebaudantoine 2a56cda55c wip enable openai 2024-10-16 15:07:27 +02:00
Jacques ROUSSEL 817cf25b37 wip bump secret 2024-10-16 14:57:15 +02:00
lebaudantoine fdaf567f5d wip revert minio 2024-10-16 14:41:41 +02:00
lebaudantoine 0cf5ab1825 wip 2024-10-16 13:40:12 +02:00
lebaudantoine c9c5d3b452 wip debug bucket 2024-10-16 13:26:47 +02:00
lebaudantoine f0b250739c wip get room from filename 2024-10-16 12:16:08 +02:00
lebaudantoine 41c9693d10 wip handle if file doesn't exist 2024-10-16 12:14:56 +02:00
lebaudantoine 83cfcacc0e wip add prompt and summary 2024-10-16 12:14:39 +02:00
lebaudantoine e8618099ac wip add some logs and allow toggling openai request 2024-10-16 12:00:07 +02:00
Jacques ROUSSEL ac183c9eb9 wip bump secrets 2024-10-16 10:47:50 +02:00
lebaudantoine 3dcc93b630 wip feedbacks 2024-10-16 10:34:17 +02:00
lebaudantoine 3614b1e803 wip create openai client 2024-10-16 10:23:01 +02:00
lebaudantoine 5e57647b34 wip connect minio 2024-10-16 10:22:41 +02:00
lebaudantoine e271c87a20 wip draft a mini webhook 2024-10-16 10:07:58 +02:00
lebaudantoine 40d1f01277 wip deploy to staging 2024-10-16 10:03:21 +02:00
lebaudantoine 8477296471 wip add recording indicator 2024-10-15 21:54:18 +02:00
lebaudantoine d200eeb6bd wip grant recording permission 2024-10-15 21:31:40 +02:00
lebaudantoine 7a51b09664 wip allow starting a recording from the frontend 2024-10-15 21:29:54 +02:00
92 changed files with 1027 additions and 5216 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.12.6-alpine3.20 as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -11,7 +11,7 @@ RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base AS back-builder
FROM base as back-builder
WORKDIR /builder
@@ -23,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
@@ -34,7 +34,7 @@ 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 \
@@ -58,7 +58,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
# ---- Core application image ----
FROM base AS core
FROM base as core
ENV PYTHONUNBUFFERED=1
@@ -93,7 +93,7 @@ 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
@@ -119,7 +119,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
View File
@@ -302,7 +302,6 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
kubectl config set-context --current --namespace=meet
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+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"
-35
View File
@@ -97,41 +97,6 @@ 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
+1
View File
@@ -1,3 +1,4 @@
version: '3.8'
services:
postgresql:
+1 -1
Submodule secrets updated: 8ef9f4513a...2ef2610071
+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)
+316
View File
@@ -0,0 +1,316 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from minio import Minio
from django.conf import settings
import openai
import logging
from ..models import Room, RoleChoices
import tempfile
import os
import smtplib
import requests
logger = logging.getLogger(__name__)
def get_prompt(transcript, date):
return f"""
Audience: Coworkers.
**Do:**
- Detect the language of the transcript and provide your entire response in the same language.
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
- Ensure the accuracy of all information and refrain from adding unverified details.
- Format the response using proper markdown and structured sections.
- Be concise and avoid repeating yourself between the sections.
- Be super precise on nickname
- Be a nit-picker
- Auto-evaluate your response
**Don't:**
- Write something your are not sure.
- Write something that is not mention in the transcript.
- Don't make mistake while mentioning someone
**Task:**
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
1. **Summary**: Write a TL;DR of the meeting.
2. **Subjects Discussed**: List the key points or issues in bullet points.
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
**Transcript**:
{transcript}
**Response:**
### Summary [Translate this title based on the transcripts language]
[Provide a brief overview of the key points discussed]
### Subjects Discussed [Translate this title based on the transcripts language]
- [Summarize each topic concisely]
### Next Steps [Translate this title based on the transcripts language]
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
"""
def get_room_and_owners(slug):
"""Wip."""
try:
room = Room.objects.get(slug=slug)
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
owners = [access.user for access in owner_accesses]
logger.info("Room %s has owners: %s", slug, owners)
except Room.DoesNotExist:
logger.error("Room with slug %s does not exist", slug)
owners = None
room = None
return room, owners
def remove_temporary_file(path):
"""Wip."""
if not path or not os.path.exists(path):
return
os.remove(path)
logger.info("Temporary file %s has been deleted.", path)
def get_blocknote_content(summary):
"""Wip."""
if not settings.BLOCKNOTE_CONVERTER_URL:
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"markdown": summary
}
logger.info("Converting summary in BlockNote.js…")
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'content' in response_data:
logger.error(f"Content is missing: %s", response_data)
return None
content = response_data['content']
logger.info("Base64 content: %s", content)
return content
def get_document_link(content, email):
"""Wip."""
logger.info("Create a document for %s", email)
if not settings.DOCS_BASE_URL:
logger.error("DOCS_BASE_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"content": content,
"owner": email
}
logger.info("Querying docs…")
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'id' in response_data:
logger.error(f"ID is missing: %s", response_data)
return None
id = response_data['id']
logger.info("Document's id: %s", id)
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
def email_owner_with_summary(room, link, owner):
"""Wip."""
logger.info("Emailing owner: %s", owner)
try:
room.email_summary(owners=[owner], link=link)
except smtplib.SMTPException:
logger.error("Error while emailing owner")
def strip_room_slug(filename):
"""Wip."""
return filename.split("_")[2].split(".")[0]
def strip_room_date(filename):
"""Wip."""
return filename.split("_")[1].split(".")[0]
def get_minio_client():
"""Wip."""
try:
return Minio(
settings.MINIO_URL,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
)
except Exception as e:
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
def download_temporary_file(minio_client, filename):
"""Wip."""
temp_file_path = None
logger.info('downloading file %s', filename)
try:
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
for data in audio_file_stream.stream(32 * 1024):
temp_audio_file.write(data)
temp_file_path = temp_audio_file.name
logger.info('Temporary file created at %s', temp_file_path)
audio_file_stream.close()
audio_file_stream.release_conn()
except Exception as e:
logger.error("An error occurred while accessing the object: %s", str(e))
return temp_file_path
# todo - discuss retry policy if the webhook fail
@api_view(["POST"])
def minio_webhook(request):
data = request.data
logger.info('Minio webhook sent %s', data)
record = data["Records"][0]
s3 = record['s3']
bucket = s3['bucket']
bucket_name = bucket['name']
object = s3['object']
filename = object['key']
if bucket_name != settings.MINIO_BUCKET:
logger.info('Not interested in this bucket: %s', bucket_name)
return Response("Not interested in this bucket")
if object['contentType'] != 'audio/ogg':
logger.info('Not interested in this file type: %s', object['contentType'])
return Response("Not interested in this file type")
room_slug = strip_room_slug(filename)
room_date = strip_room_date(filename)
logger.info('file received %s for room %s', filename, room_slug)
minio_client = get_minio_client()
temp_file_path = None
summary = None
try:
temp_file_path = download_temporary_file(minio_client, filename)
if settings.OPENAI_ENABLE and temp_file_path:
logger.info('Initiating OpenAI client …')
openai_client = openai.OpenAI(
api_key=settings.OPENAI_API_KEY,
)
with open(temp_file_path, "rb") as audio_file:
logger.info('Querying transcription …')
transcript = openai_client.audio.transcriptions.create(
model="whisper-1",
file=audio_file
)
logger.info('Transcript: \n %s', transcript)
prompt = get_prompt(transcript.text, room_date)
logger.info('Prompt: \n %s', prompt)
summary_response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
{"role": "user", "content": prompt}
],
)
summary = summary_response.choices[0].message.content
logger.info('Summary: \n %s', summary)
except Exception as e:
logger.error("An error occurred: %s", str(e))
raise
finally:
remove_temporary_file(temp_file_path)
if not summary:
logger.error("Empty summary.")
return Response("")
room, owners = get_room_and_owners(room_slug)
if not owners or not room:
logger.error("No owners in room %s", room_slug)
return Response("")
content = get_blocknote_content(summary)
if not content:
logger.error("Empty content.")
return Response("")
owner = owners[0]
link = get_document_link(content, owner.email)
if not link:
logger.error("Empty link.")
return Response("")
email_owner_with_summary(room, link, owner)
return Response("")
+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")
+14 -175
View File
@@ -1,7 +1,6 @@
"""API endpoints"""
import uuid
from logging import getLogger
from django.conf import settings
from django.db.models import Q
@@ -15,41 +14,17 @@ from rest_framework import (
pagination,
viewsets,
)
from rest_framework import (
exceptions as drf_exceptions,
)
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.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):
"""
@@ -211,6 +186,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
@@ -259,87 +241,12 @@ 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,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
room = self.get_object()
try:
recording = models.Recording.objects.get(
room=room, status=models.RecordingStatusChoices.ACTIVE
)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound(
"No active recording found for this room."
) from e
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
try:
worker_manager.stop(recording)
except RecordingStopError:
return drf_response.Response(
{"error": f"Recording failed to stop for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"message": f"Recording stopped for room {room.slug}."}
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
)
@@ -386,71 +293,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."
)
recording.status = 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
+6
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)
-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'),
)
]
+27 -279
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
@@ -16,6 +15,8 @@ from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from timezone_field import TimeZoneField
logger = getLogger(__name__)
@@ -39,46 +40,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")
@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
@@ -141,14 +102,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)(),
@@ -207,39 +165,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,211 +329,22 @@ class Room(Resource):
super().clean_fields(exclude=exclude)
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
def email_summary(self, owners, 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": "Votre résumé est prêt",
"link": link,
"room": self.slug,
}
msg_html = render_to_string("mail/html/summary.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", 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="Votre résumé est prêt",
from_email=settings.EMAIL_FROM,
message=msg_plain,
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"""
room = models.ForeignKey(
Room,
on_delete=models.CASCADE,
related_name="recordings",
verbose_name=_("Room"),
)
status = models.CharField(
max_length=20,
choices=RecordingStatusChoices.choices,
default=RecordingStatusChoices.INITIATED,
)
worker_id = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Worker ID"),
help_text=_(
"Enter an identifier for the worker recording."
"This ID is retained even when the worker stops, allowing for easy tracking."
),
)
mode = models.CharField(
max_length=20,
choices=RecordingModeChoices.choices,
default=RecordingModeChoices.SCREEN_RECORDING,
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
class Meta:
db_table = "meet_recording"
ordering = ("-created_at",)
verbose_name = _("Recording")
verbose_name_plural = _("Recordings")
constraints = [
models.UniqueConstraint(
fields=["room"],
condition=models.Q(
status__in=[
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.INITIATED,
]
),
name="unique_initiated_or_active_recording_per_room",
)
]
def __str__(self):
return f"Recording {self.id} ({self.status})"
def get_abilities(self, user):
"""Compute and return abilities for a given user on the recording."""
roles = set(get_resource_roles(self, user))
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_final_status = RecordingStatusChoices.is_final(self.status)
return {
"destroy": is_owner_or_admin and is_final_status,
"partial_update": False,
"retrieve": is_owner_or_admin,
"stop": is_owner_or_admin and not is_final_status,
"update": False,
}
def is_savable(self) -> bool:
"""Determine if the recording can be saved based on its current status."""
return self.status in {
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.STOPPED,
}
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role.
Recording Status Flow:
1. INITIATED: Initial state when recording is requested
2. ACTIVE: Recording is currently in progress
3. STOPPED: Recording has been stopped by user/system
4. SAVED: Recording has been successfully processed and stored
Error States:
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
Warning: Worker failures may lead to database inconsistency between the actual
recording state and its status in the database.
"""
recording = models.ForeignKey(
Recording,
on_delete=models.CASCADE,
related_name="accesses",
)
class Meta:
db_table = "meet_recording_access"
ordering = ("-created_at",)
verbose_name = _("Recording/user relation")
verbose_name_plural = _("Recording/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "recording"],
condition=models.Q(user__isnull=False), # Exclude null users
name="unique_recording_user",
violation_error_message=_("This user is already in this recording."),
),
models.UniqueConstraint(
fields=["team", "recording"],
condition=models.Q(team__gt=""), # Exclude empty string teams
name="unique_recording_team",
violation_error_message=_("This team is already in this recording."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_recording_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
@@ -1 +0,0 @@
"""Meet event parser classes, authentication and exceptions."""
@@ -1,96 +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
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."""
-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 == ""
+2 -2
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 get_frontend_configuration, viewsets, demo
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"
)
@@ -25,6 +24,7 @@ urlpatterns = [
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path("minio-webhook/", demo.minio_webhook, name="demo"),
]
),
),
+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",
+58 -58
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/
@@ -327,10 +308,6 @@ class Base(Configuration):
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
@@ -390,16 +367,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 = {
@@ -409,44 +376,61 @@ 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
)
ANALYTICS_KEY = values.Value(
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_ENABLE", environ_prefix=None
# todo - totally wip
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
RECORDING_VERIFY_SSL = values.BooleanValue(
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", 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",
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,
)
RECORDING_EVENT_PARSER_CLASS = values.Value(
"core.recording.event.parsers.MinioParser",
environ_name="RECORDING_EVENT_PARSER_CLASS",
environ_prefix=None,
OPENAI_API_KEY = values.Value(
None, environ_name="OPENAI_API_KEY", environ_prefix=None
)
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
OPENAI_ENABLE = values.BooleanValue(
True, environ_name="OPENAI_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
# todo - totally wip
MINIO_ACCESS_KEY = values.Value(
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
MINIO_SECRET_KEY = values.Value(
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
)
MINIO_URL = values.Value(
None, environ_name="MINIO_URL", environ_prefix=None
)
MINIO_BUCKET = values.Value(
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
)
BLOCKNOTE_CONVERTER_URL = values.Value(
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
)
DOCS_BASE_URL = values.Value(
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
)
# pylint: disable=invalid-name
@@ -568,6 +552,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"]
@@ -590,6 +576,20 @@ class Production(Base):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
# Security
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
+3 -2
View File
@@ -42,6 +42,7 @@ dependencies = [
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
@@ -57,7 +58,8 @@ dependencies = [
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.7.0",
"aiohttp==3.10.10",
"minio==7.2.9",
"openai==1.51.2"
]
[project.urls]
@@ -70,7 +72,6 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"pyfakefs==5.6.0",
+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.26-alpine as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
-10
View File
@@ -2,12 +2,6 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
// todo - refactor it in a proper place
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
export interface ApiConfig {
analytics?: {
id: string
@@ -17,10 +11,6 @@ export interface ApiConfig {
id: string
}
silence_livekit_debug_logs?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
}
}
const fetchConfig = (): Promise<ApiConfig> => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

@@ -1,6 +1,4 @@
export type ApiUser = {
id: string
email: string
full_name: string
last_name: string
}
@@ -1,241 +0,0 @@
import firstSlide from '@/assets/intro-slider/1_solo.png'
import secondSlide from '@/assets/intro-slider/2_multiple.png'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// todo - extract in a proper env variable
const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
const Heading = styled('h2', {
base: {
width: 'fit-content',
marginBottom: 0,
fontSize: '1.5rem',
marginTop: '0.75rem',
lineHeight: '2rem',
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
},
})
const Body = styled('p', {
base: {
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
lineHeight: '1.2rem',
fontSize: '1rem',
},
})
const Image = styled('img', {
base: {
maxHeight: '330px',
height: '100%',
width: 'fit-content',
},
})
const Dot = styled('div', {
base: {
borderRadius: '50%',
display: 'inline-block',
height: '.375rem',
margin: '0 .25rem',
width: '.375rem',
},
variants: {
selected: {
true: {
backgroundColor: '#000091',
},
false: {
backgroundColor: '#CACAFB',
},
},
},
})
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'space-between',
textAlign: 'center',
},
})
const ButtonVerticalCenter = styled('div', {
base: {
marginTop: '10.3125rem',
transform: 'translateY(-50%)',
},
})
const SlideContainer = styled('div', {
base: {
alignItems: 'stretch',
display: 'flex',
position: 'relative',
},
})
const Slide = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'start',
minHeight: '550px',
minWidth: '200px',
width: '22.625rem',
},
variants: {
visible: {
true: {
visibility: 'visible',
position: 'static',
},
false: {
visibility: 'hidden',
position: 'absolute',
},
},
},
defaultVariants: {
visible: false,
},
})
const TextAnimation = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '0.5rem',
},
variants: {
visible: {
true: {
opacity: 1,
transform: 'none',
transition: 'opacity ease-in .3s, transform ease-in .3s',
},
false: {
opacity: 0,
transform: 'translateX(-30%)',
},
},
},
})
type Slide = {
key: string
img: string
isAvailableInBeta?: boolean
}
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
key: 'slide1',
img: firstSlide,
},
{
key: 'slide2',
img: secondSlide,
},
{
key: 'slide3',
img: thirdSlide,
isAvailableInBeta: true,
},
]
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const NUMBER_SLIDES = SLIDES.length
return (
<Container>
<div
className={css({
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
})}
>
<div>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
</ButtonVerticalCenter>
</div>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex}>
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
{slide.isAvailableInBeta && (
<LinkButton
href={BETA_USERS_FORM_URL}
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
{t('beta.text')}
</LinkButton>
)}
</TextAnimation>
</Slide>
))}
</SlideContainer>
<div>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
</ButtonVerticalCenter>
</div>
</div>
<div
className={css({
marginTop: '0.5rem',
})}
>
{SLIDES.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}
</div>
</Container>
)
}
@@ -1,23 +0,0 @@
import { A, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
const MANIFEST_LINK =
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
export const MoreLink = () => {
const { t } = useTranslation('home')
return (
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
<A
href={MANIFEST_LINK}
target="_blank"
rel="noopener noreferrer"
aria-label={t('moreLinkLabel')}
>
{t('moreLink')}
</A>{' '}
{t('moreAbout')}
</Text>
)
}
+69 -199
View File
@@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu } from '@/primitives'
import { HStack, styled } from '@/styled-system/jsx'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
@@ -13,127 +14,7 @@ import { usePersistentUserChoices } from '@livekit/components-react'
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
display: 'inline-flex',
flexDirection: 'column',
height: '100%',
minHeight: '100%',
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100%-2rem)',
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
lg: {
flexDirection: 'row',
justifyContent: 'space-evenly',
width: '100%',
padding: 0,
},
})}
>
{children}
</div>
)
}
const LeftColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
textAlign: 'center',
display: 'inline-flex',
flexDirection: 'column',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '39rem',
width: '100%',
padding: '1rem 3%',
marginTop: 'auto',
lg: {
margin: 0,
textAlign: 'left',
alignItems: 'flex-start',
flexShrink: '1',
flexBasis: '45rem',
maxWidth: '45rem',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const RightColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden',
padding: '1rem 3%',
marginBottom: 'auto',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '39rem',
lg: {
margin: 0,
flexBasis: '45%',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const Separator = styled('div', {
base: {
borderBottom: '1px solid',
borderColor: '#747775',
marginTop: '2.5rem',
maxWidth: '30rem',
width: '100%',
},
})
const Heading = styled('h1', {
base: {
fontSize: '3rem',
lineHeight: '3.2rem',
letterSpacing: '0',
fontWeight: '500',
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
marginBottom: 0,
paddingBottom: '0.75rem',
},
})
const IntroText = styled('div', {
base: {
marginBottom: '3rem',
fontSize: '1.375rem',
lineHeight: '1.8rem',
textWrap: 'pretty',
maxWidth: '28rem',
},
})
import { useState } from 'react'
export const Home = () => {
const { t } = useTranslation('home')
@@ -149,83 +30,72 @@ export const Home = () => {
return (
<UserAware>
<Screen>
<Columns>
<LeftColumn>
<Heading>{t('heading')}</Heading>
<IntroText>{t('intro')}</IntroText>
<HStack gap="gutter" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton hint={false} />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
<Centered width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
<Text as="p" variant="h3">
{t('intro')}
</Text>
{!isLoggedIn && (
<Text margin="sm" variant="note">
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
<Separator />
<div
className={css({
display: { base: 'none', lg: 'inline' },
})}
>
<MoreLink />
</div>
</LeftColumn>
<RightColumn>
<IntroSlider />
<div
className={css({
display: { base: 'inline', lg: 'none' },
})}
>
<MoreLink />
</div>
</RightColumn>
</Columns>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Centered>
<LaterMeetingDialog
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
@@ -89,13 +89,6 @@ export const Conference = ({
)
}
// Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen header={false}>
@@ -106,7 +99,6 @@ export const Conference = ({
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
connectOptions={connectOptions}
>
<VideoConference />
{showInviteDialog && (
@@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
export const Join = ({
onSubmit,
@@ -10,7 +9,6 @@ export const Join = ({
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
return (
<Screen layout="centered">
@@ -21,8 +19,7 @@ export const Join = ({
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
userLabel={t('join.userLabel')}
/>
</CenteredContent>
</Screen>
@@ -0,0 +1,53 @@
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingIndicator = () => {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording)
const egressSnap = useSnapshot(egressStore)
const egressIsStopping = egressSnap.egressIsStopping
useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => {
if (!isRecording) {
egressStore.egressIsStopping = false
}
setIsRecording(isRecording)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
}
}, [room])
const getStatus = () => {
if (egressIsStopping) {
return 'saving recording'
}
if (isRecording) {
return 'recording'
}
if (!isRecording) {
return 'available'
}
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
}}
>
Room status: {getStatus()}
</div>
)
}
@@ -0,0 +1,56 @@
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useParams } from 'wouter'
export const useRecordRoom = () => {
const data = useRoomData()
const { roomId: roomSlug } = useParams()
const recordRoom = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
if (!roomSlug) {
throw new Error('Room ID is not available')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'/twirp/livekit.Egress/StartRoomCompositeEgress'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room_name: data.livekit.room,
audio_only: true,
file_outputs: [
{
file_extension: 'ogg',
filepath: `{room_name}_{time}_${roomSlug}`,
},
],
}),
}
)
}
const stopRecordingRoom = (egressId: string) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
return fetchServerApi(
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
egressId,
}),
}
)
}
return { recordRoom, stopRecordingRoom }
}
@@ -10,6 +10,7 @@ import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
@@ -34,6 +35,7 @@ export const OptionsMenuItems = ({
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<RecordingMenuItem />
</Section>
<Separator />
<Section>
@@ -0,0 +1,52 @@
import { MenuItem } from 'react-aria-components'
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => {
const { recordRoom, stopRecordingRoom } = useRecordRoom()
const egressSnap = useSnapshot(egressStore)
const egressId = egressSnap.egressId
const [isPending, setIsPending] = useState(false)
const handleAction = async () => {
if (egressId) {
setIsPending(true)
egressStore.egressIsStopping = true
const response = await stopRecordingRoom(egressId)
console.log(response)
egressStore.egressId = undefined
setIsPending(false)
} else {
setIsPending(true)
const response = await recordRoom()
egressStore.egressId = response['egress_id'] as string
setIsPending(false)
}
}
return (
<MenuItem
isDisabled={isPending}
className={menuItemRecipe({ icon: true })}
onAction={handleAction}
>
{egressId ? (
<>
<RiPauseCircleLine size={18} />
Arrêter l'enregistrement
</>
) : (
<>
<RiRecordCircleLine size={18} />
Enregistrer la réunion
</>
)}
</MenuItem>
)
}
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
const LayoutWrapper = styled(
'div',
@@ -149,13 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isSidePanelOpen } = useSidePanel()
return (
<div
className="lk-video-conference"
{...props}
style={{
overflowX: 'hidden',
}}
>
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
@@ -171,6 +166,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
}}
>
<RecordingIndicator />
<LayoutWrapper>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
@@ -18,7 +18,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name || user?.email }}
values={{ user: user?.email }}
components={[<Badge />]}
/>
</P>
@@ -48,7 +48,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name || user?.email }}
values={{ user: user?.email }}
components={[<Badge />]}
/>
</P>
+4 -1
View File
@@ -22,6 +22,9 @@ export const Header = () => {
return (
<div
className={css({
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
paddingY: 1,
paddingX: 1,
flexShrink: 0,
@@ -73,7 +76,7 @@ export const Header = () => {
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
{user?.full_name || user?.email}
{user.email}
</Button>
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
-32
View File
@@ -10,9 +10,6 @@
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": "",
"moreLinkLabel": "",
"moreLink": "",
"moreAbout": "",
"createMenu": {
"laterOption": "",
"instantOption": ""
@@ -23,34 +20,5 @@
"copy": "",
"copied": "",
"permissions": ""
},
"introSlider": {
"previous": {
"label": "",
"tooltip": ""
},
"beta": {
"text": "",
"tooltip": ""
},
"next": {
"label": "",
"tooltip": ""
},
"slide1": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide2": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide3": {
"title": "",
"body": "",
"imgAlt": ""
}
}
}
+1
View File
@@ -22,6 +22,7 @@
"joinMeeting": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
},
+2 -34
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Create a meeting",
"heading": "Simple and Secure Video Conferencing",
"intro": "Communicate and work with ease, without compromising your sovereignty",
"heading": "Welcome in Visio",
"intro": "Work easily, from anywhere.",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
"joinInputLabel": "Meeting link",
@@ -10,9 +10,6 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more",
"moreAbout": "about Visio",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
@@ -23,34 +20,5 @@
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
},
"introSlider": {
"previous": {
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
"tooltip": "Fill out the form"
},
"slide1": {
"title": "Try Visio to simplify your daily tasks",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, up to 15 participants, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+1
View File
@@ -22,6 +22,7 @@
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
},
+2 -34
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Créer une réunion",
"heading": "Visioconférences simples et sécurisées",
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
"heading": "Visio",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
"joinInputLabel": "Lien complet ou code de la réunion",
@@ -10,9 +10,6 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur Visio",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
@@ -23,34 +20,5 @@
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"introSlider": {
"previous": {
"label": "précédent",
"tooltip": "précédent"
},
"next": {
"label": "suivant",
"tooltip": "suivant"
},
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
},
"slide1": {
"title": "Essayez Visio pour simplifier votre quotidien",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, jusqu'à 15 participants, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
}
}
}
+1
View File
@@ -22,6 +22,7 @@
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
},
+1 -1
View File
@@ -9,7 +9,7 @@ const link = cva({
borderRadius: 2,
transition: 'all 0.2s',
'&[data-hovered]': {
textDecorationThickness: '2px',
textDecoration: 'none',
},
'&[data-pressed]': {
textDecoration: 'underline',
+11
View File
@@ -0,0 +1,11 @@
import { proxy } from 'valtio'
type State = {
egressId: string | undefined
egressIsStopping: boolean
}
export const egressStore = proxy<State>({
egressId: undefined,
egressIsStopping: false,
})
@@ -1,43 +0,0 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
egress:
log_level: debug
ws_url: ws://livekit-livekit-server:80
insecure: true
enable_chrome_sandbox: true
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
api_key: {{ $key }}
api_secret: {{ $value }}
{{- end }}
{{- end }}
redis:
address: redis-master:6379
password: pass
s3:
access_key: meet
secret: password
region: local
bucket: meet-media-storage
endpoint: http://minio:9000
force_path_style: true
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts:
- livekit-egress.127.0.0.1.nip.io
secretName: livekit-egress-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
nodeSelector: {}
resources: {}
@@ -4,20 +4,13 @@ terminationGracePeriodSeconds: 18000
livekit:
log_level: debug
rtc:
use_external_ip: false
use_external_ip: true
port_range_start: 50000
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
password: pass
address:
keys:
turn:
enabled: true
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
loadBalancer:
type: nginx
+2 -5
View File
@@ -24,7 +24,7 @@ backend:
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
@@ -47,13 +47,10 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
migrate:
@@ -42,7 +42,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr
@@ -94,25 +94,26 @@ backend:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ANALYTICS_KEY: mwuxTKi8o2xzWH54
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
name: impress-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
name: impress-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
name: impress-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
name: impress-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
+22 -3
View File
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "main"
tag: "v-hackathon"
backend:
migrateJobAnnotations:
@@ -41,7 +41,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr
@@ -93,6 +93,7 @@ backend:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
@@ -113,6 +114,24 @@ backend:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: backend
key: OPENAI_API_KEY
OPENAI_ENABLE: True
MINIO_ACCESS_KEY:
secretKeyRef:
name: backend
key: MINIO_ACCESS_KEY
MINIO_SECRET_KEY:
secretKeyRef:
name: backend
key: MINIO_SECRET_KEY
MINIO_URL:
secretKeyRef:
name: backend
key: MINIO_URL
createsuperuser:
command:
@@ -126,7 +145,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "main"
tag: "v-hackathon"
ingress:
enabled: true
@@ -1,8 +1,8 @@
apiVersion: core.libre.sh/v1alpha1
kind: Bucket
metadata:
name: impress-media-storage
name: meet-media-storage
namespace: {{ .Release.Namespace | quote }}
spec:
provider: data
versioned: true
versioned: true
-31
View File
@@ -45,28 +45,6 @@ releases:
enabled: true
autoGenerated: true
- name: minio
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/minio
version: 12.10.10
values:
- auth:
rootUser: meet
rootPassword: password
- provisioning:
enabled: true
buckets:
- name: meet-media-storage
versioning: true
- ingress:
enabled: true
hostname: minio-console.127.0.0.1.nip.io
servicePort: 9001
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: nginx
- name: redis
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
@@ -107,12 +85,3 @@ releases:
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
- name: livekit-egress
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: livekit/egress
values:
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
+13
View File
@@ -15,3 +15,16 @@ stringData:
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
{{- if .Values.openaiApiKey }}
OPENAI_API_KEY: {{ .Values.openaiApiKey }}
{{- end }}
{{- if .Values.minioAccessKey }}
MINIO_ACCESS_KEY: {{ .Values.minioAccessKey }}
{{- end }}
{{- if .Values.minioSecretKey }}
MINIO_SECRET_KEY: {{ .Values.minioSecretKey }}
{{- end }}
{{- if .Values.minioUrl }}
MINIO_URL: {{ .Values.minioUrl }}
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text font-size="14px">
<p>Cher utilisateur,</p>
<p>La réunion <strong>{{room}}</strong> a été transcrite et résumée avec succès.</p>
</mj-text>
<mj-button href="{{link}}" font-size="14px" background-color="#346DB7" color="white">
Obtenez votre résumé
</mj-button>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>