Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f117d5320 | |||
| f589b6374b | |||
| 4232c0a303 | |||
| a5454e48b7 | |||
| 591706f363 | |||
| 1ea84b6e6c | |||
| c0d101a326 | |||
| 64cfcb6c0f | |||
| cdd69b741a | |||
| f5a87cc210 | |||
| 4d4ddb9ee8 | |||
| 70dbf94f7b | |||
| 0ad37ee6de | |||
| ed4f7dcf6c | |||
| c54773008c | |||
| 6848321bcc | |||
| f161a5cf6a | |||
| 51270571bc | |||
| c6fdeaf1e9 | |||
| f1959cbb3a | |||
| 84cea2f658 | |||
| db1fdb9871 | |||
| a8618239d1 | |||
| 0eb283b75a | |||
| 0104cabc5e | |||
| dbfba564c5 | |||
| 4830206bb2 | |||
| e4f30f926c | |||
| fc61f58596 | |||
| e0021dbb80 | |||
| 83914f8307 | |||
| be54709598 | |||
| 5b76ea492b | |||
| 46934a84d1 | |||
| deb9ab1a1d | |||
| d0fd16d7d2 | |||
| 0b8181e5ce | |||
| 8b2365d5f9 | |||
| 2dd16d1f40 | |||
| fa9484b630 | |||
| 1e0e495cd8 | |||
| c270299179 | |||
| d9a84e5f0f | |||
| 0f64d3cf3a | |||
| d1e008a844 | |||
| 3be5a5afc6 | |||
| 31468a5e7c | |||
| b342b9d526 | |||
| f7e7c3ba22 | |||
| 3902b02691 | |||
| 7ce4390740 | |||
| 94d18cffe4 | |||
| ad0c3eea66 | |||
| 459bbf65a8 | |||
| 4b4f16e93f | |||
| 3e3f964ac5 | |||
| bcb111defb | |||
| 91a3aa4033 | |||
| 05b74365fa | |||
| 4eea4d01cd | |||
| 8caa6b7820 | |||
| e7e7bb0d09 | |||
| 819d3784f7 | |||
| d83ccb3d9d | |||
| a083c8cc3a | |||
| a299cae8e8 | |||
| 60b7a35ed1 | |||
| 39fdffd9a8 | |||
| e3779d9a6e | |||
| b74d6e72f9 | |||
| 9be826fd14 | |||
| 4fe01ae2bf | |||
| 300756b323 | |||
| 0af79912c5 | |||
| 446270c153 | |||
| e6377ce182 | |||
| a6b6cb7787 | |||
| 50146e95f4 | |||
| 35741a1bc1 | |||
| b12b14b277 | |||
| bb30f25953 | |||
| ebbdebf444 | |||
| fde10e7556 | |||
| 0dbb256e9f | |||
| 33c8a3a9e4 | |||
| dd0cb61ebc | |||
| e92f084afb | |||
| 4a53005ae3 | |||
| d7d82130d9 | |||
| 16bee00eb4 | |||
| 0f70b544ed | |||
| 2903442a67 | |||
| be884a8ca1 | |||
| fe70165076 | |||
| 290ba4274a | |||
| d2f239e6a2 | |||
| c61fc40671 | |||
| 10705ca3ac | |||
| 6b03ebb393 | |||
| 0627510f10 | |||
| f9233c8fb3 | |||
| 0b672ef8da | |||
| 0b0efa5bab | |||
| 0958206057 | |||
| 4e13ace815 | |||
| 30ee0fdce3 | |||
| 17453a6cee | |||
| c1e8ebc129 | |||
| a1dbf02f9c | |||
| c4206b4b24 | |||
| 732df0768c | |||
| 8a96b6939a | |||
| e78b8c69fe | |||
| 6e49ab3230 | |||
| 200e2d3c2f | |||
| 4ffef3f94a | |||
| 8e62f2ecd3 | |||
| d024fb1b95 | |||
| 7e49f0f661 | |||
| fb8c0fd1b5 | |||
| b07e4c58b4 | |||
| e6bbf2d3c8 | |||
| 28899a2aaf | |||
| a5bc66a921 | |||
| adb09410da | |||
| 061f12e7e2 | |||
| 24e819a533 | |||
| 5c48ac100a | |||
| 4434f0265d | |||
| 0522696842 | |||
| 8516782d79 | |||
| b06880be15 | |||
| 5de9cec688 | |||
| 7dfd86873c | |||
| 7cee409d22 | |||
| 7ddc448c88 | |||
| 6fcb69bd3c | |||
| 6aed4cb751 | |||
| f433f59d3f | |||
| fba9910705 | |||
| dcba3330f7 | |||
| 82bb5f0f8b | |||
| 0fd06ef6c0 | |||
| bd4dec6f27 | |||
| a987830fb3 | |||
| 7f09636791 | |||
| 3460ec8808 | |||
| 86cda75b39 | |||
| 7afa165013 | |||
| e11bdc6d28 | |||
| 8309545ec6 | |||
| 840033fcbc | |||
| 28ca2d6c37 | |||
| 4e77458116 | |||
| d4532eeb64 | |||
| b84628ee95 | |||
| f6f1222f47 | |||
| 7278613b20 | |||
| d370a4db10 | |||
| c1bc379744 | |||
| 5ef6359b7c | |||
| 04d76acce5 | |||
| eeb71f90bc | |||
| 5db4b09106 | |||
| 11cd85d4eb | |||
| ccbeeba68f | |||
| f3ea0fca71 | |||
| cb4acc32e5 | |||
| 60f5c8486b | |||
| ed3a26d449 | |||
| cb4c058c5d | |||
| c504b5262b | |||
| 3b3816b333 | |||
| 15e922f9df | |||
| b69f777e5a | |||
| 67d004fbda | |||
| 4449b578bd | |||
| 1f0d2ce335 | |||
| 0d1d0662ee | |||
| 0056b4cc29 | |||
| d7892cde9f | |||
| 372db49e94 |
@@ -117,10 +117,57 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-summary
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
@@ -302,7 +302,9 @@ 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
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -118,6 +118,8 @@ $ make build-k8s-cluster
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
or
|
||||
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -28,7 +27,18 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.', '../../docker', '../../.dockerignore'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
@@ -5,7 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
cat <<EOF | kind create cluster --name visio --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
@@ -52,10 +52,6 @@ nodes:
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
@@ -68,7 +64,7 @@ echo "3. Add the registry config to the nodes"
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
for node in $(kind get nodes --name visio); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
@@ -136,3 +132,8 @@ echo "6. Install ingress-nginx"
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
|
||||
echo "7. Setup namespace"
|
||||
kubectl create ns meet
|
||||
kubectl config set-context --current --namespace=meet
|
||||
kubectl -n meet create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
@@ -22,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -52,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"sub",
|
||||
"admin_email",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
@@ -60,9 +74,24 @@ 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")
|
||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||
search_fields = ("id", "sub", "admin_email", "email")
|
||||
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")
|
||||
|
||||
|
||||
class ResourceAccessInline(admin.TabularInline):
|
||||
@@ -79,23 +108,16 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
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")
|
||||
list_select_related = ("creator",)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"worker_id",
|
||||
"room",
|
||||
"creator",
|
||||
"mode",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("status", "room", "creator", "worker_id", "mode")}),
|
||||
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Meet analytics class.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from june import analytics as jAnalytics
|
||||
|
||||
|
||||
class Analytics:
|
||||
"""Analytics integration
|
||||
|
||||
This class wraps the June analytics code to avoid coupling our code directly
|
||||
with this third-party library. By doing so, we create a generic interface
|
||||
for analytics that can be easily modified or replaced in the future.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
key = getattr(settings, "ANALYTICS_KEY", None)
|
||||
|
||||
if key is not None:
|
||||
jAnalytics.write_key = key
|
||||
|
||||
self._enabled = key is not None
|
||||
|
||||
def _is_anonymous_user(self, user):
|
||||
"""Check if the user is anonymous."""
|
||||
return user is None or user.is_anonymous
|
||||
|
||||
def identify(self, user, **kwargs):
|
||||
"""Identify a user"""
|
||||
|
||||
if self._is_anonymous_user(user) or not self._enabled:
|
||||
return
|
||||
|
||||
traits = kwargs.pop("traits", {})
|
||||
traits.update({"email": user.email_anonymized})
|
||||
|
||||
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
|
||||
|
||||
def track(self, user, **kwargs):
|
||||
"""Track an event"""
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
event_data = {}
|
||||
if self._is_anonymous_user(user):
|
||||
event_data["anonymous_id"] = str(uuid.uuid4())
|
||||
else:
|
||||
event_data["user_id"] = user.sub
|
||||
|
||||
jAnalytics.track(**event_data, **kwargs)
|
||||
|
||||
|
||||
analytics = Analytics()
|
||||
@@ -37,6 +37,10 @@ def get_frontend_configuration(request):
|
||||
"""Returns the frontend configuration dict as configured in settings."""
|
||||
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)
|
||||
|
||||
@@ -67,30 +67,11 @@ class RoomPermissions(permissions.BasePermission):
|
||||
return obj.is_administrator(user)
|
||||
|
||||
|
||||
class IsRoomOwnerOrAdministrator(permissions.BasePermission):
|
||||
"""Temporary"""
|
||||
|
||||
message = "You must be an admin or owner to start a recording."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Get the room object
|
||||
room = view.get_object()
|
||||
|
||||
if not room.is_owner(request.user) and not room.is_administrator(request.user):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ResourceAccessPermission(permissions.BasePermission):
|
||||
class ResourceAccessPermission(IsAuthenticated):
|
||||
"""
|
||||
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.
|
||||
@@ -102,6 +83,24 @@ class ResourceAccessPermission(permissions.BasePermission):
|
||||
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."""
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email"]
|
||||
read_only_fields = ["id", "email"]
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
@@ -87,6 +87,15 @@ 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."""
|
||||
|
||||
@@ -121,8 +130,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
del output["configuration"]
|
||||
|
||||
if role is not None or instance.is_public:
|
||||
slug = f"{instance.id!s}".replace("-", "")
|
||||
|
||||
slug = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = {
|
||||
@@ -136,3 +144,36 @@ 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")
|
||||
|
||||
@@ -26,21 +26,25 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core.recording.event import (
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
StorageEventAuthentication,
|
||||
get_parser,
|
||||
)
|
||||
from core.recording.worker import (
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
worker_service_provider,
|
||||
)
|
||||
|
||||
from ..analytics import analytics
|
||||
from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
@@ -224,96 +228,10 @@ class RoomViewSet(
|
||||
},
|
||||
}
|
||||
else:
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Get Room",
|
||||
properties={"slug": instance.slug},
|
||||
)
|
||||
data = self.get_serializer(instance).data
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.IsRoomOwnerOrAdministrator,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Start recording a room."""
|
||||
|
||||
room = self.get_object()
|
||||
mode = request.data.get("mode")
|
||||
|
||||
if mode is None:
|
||||
return drf_response.Response(
|
||||
{"error": "Recording mode is required."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
creator=request.user, room=room, mode=mode
|
||||
)
|
||||
|
||||
worker_service = worker_service_provider.create(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.IsRoomOwnerOrAdministrator,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Stop room recording."""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
# fixme - do I need to be the creator of the recording to stop it?
|
||||
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 = worker_service_provider.create(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}."}
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
user = self.request.user
|
||||
@@ -342,12 +260,87 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Create Room",
|
||||
properties={
|
||||
"slug": room.slug,
|
||||
},
|
||||
@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}."}
|
||||
)
|
||||
|
||||
|
||||
@@ -396,11 +389,29 @@ class ResourceAccessViewSet(
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
|
||||
class RecordingViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API endpoints to access and perform actions on recording.
|
||||
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"],
|
||||
@@ -435,13 +446,21 @@ class RecordingViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
|
||||
if not recording.is_savable():
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||
f"in an error state or has already been saved."
|
||||
" in an error state or has already been saved."
|
||||
)
|
||||
|
||||
recording.status = models.RecordingStatusChoices.SAVED
|
||||
recording.save()
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
# todo - trigger post-processing
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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 _
|
||||
|
||||
@@ -10,8 +11,6 @@ from mozilla_django_oidc.auth import (
|
||||
|
||||
from core.models import User
|
||||
|
||||
from ..analytics import analytics
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -68,36 +67,76 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if sub is None:
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
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
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
analytics.identify(user=user)
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
|
||||
sub = claims.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
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
|
||||
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
password="!", # noqa: S106
|
||||
)
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -22,8 +22,6 @@ from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
from ..analytics import analytics
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
@@ -100,10 +98,6 @@ 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)
|
||||
|
||||
@@ -151,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent loging flow."""
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
@@ -168,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent loging flow."""
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
@@ -23,6 +23,8 @@ 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")
|
||||
|
||||
@@ -32,6 +34,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Resource
|
||||
skip_postgeneration_save = True
|
||||
|
||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
|
||||
@@ -45,6 +48,8 @@ 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."""
|
||||
@@ -72,11 +77,45 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
skip_postgeneration_save = True
|
||||
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
room = factory.SubFactory(RoomFactory)
|
||||
status = models.RecordingStatusChoices.INITIATED
|
||||
mode = factory.fuzzy.FuzzyChoice(
|
||||
[choice[0] for choice in models.RecordingModeChoices.choices]
|
||||
)
|
||||
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,41 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-24 18:51
|
||||
|
||||
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.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
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=[('active', 'Active'), ('stopped', 'Stopped'), ('aborted', 'Aborted'), ('saved', 'Saved'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='active', max_length=20)),
|
||||
('mode', models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the type of recording being performed.', max_length=20, verbose_name='Recording mode')),
|
||||
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording. This ID is retained even whenthe worker stops, allowing for easy tracking and management of recorded data.', max_length=255, null=True, verbose_name='Worker ID')),
|
||||
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to=settings.AUTH_USER_MODEL, verbose_name='Creator')),
|
||||
('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',),
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('status', 'active')), fields=('room',), name='unique_active_recording_per_room')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-06 14:31
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recording',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
|
||||
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording',
|
||||
'verbose_name_plural': 'Recordings',
|
||||
'db_table': 'meet_recording',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecordingAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording/user relation',
|
||||
'verbose_name_plural': 'Recording/user relations',
|
||||
'db_table': 'meet_recording_access',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recording',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
from core.models import RoleChoices
|
||||
|
||||
def merge_duplicate_user_accounts(apps, schema_editor):
|
||||
"""Merge user accounts that share the same email address.
|
||||
|
||||
Historical Context:
|
||||
Previously, ProConnect authentication could return users with the same email
|
||||
but different sub, leading to duplicate user accounts. While the application
|
||||
now prevents this scenario, this migration is needed to clean up existing
|
||||
duplicate accounts to ensure users can continue to connect without being blocked
|
||||
by unique email constraints.
|
||||
|
||||
Performance of this migration is poor, this implementation prioritizes readability
|
||||
and maintainability. Consider refactoring this code to avoid individual db queries
|
||||
on each iteration.
|
||||
"""
|
||||
|
||||
User = apps.get_model('core', 'User')
|
||||
ResourceAccess = apps.get_model('core', 'ResourceAccess')
|
||||
|
||||
emails_with_duplicates = (
|
||||
User.objects.values('email')
|
||||
.annotate(count=Count('id'))
|
||||
.filter(count__gt=1)
|
||||
.values_list('email', flat=True)
|
||||
)
|
||||
|
||||
for email in emails_with_duplicates:
|
||||
# Keep the oldest user
|
||||
primary_user = User.objects.filter(email=email).order_by('created_at').first()
|
||||
duplicate_user_accounts = User.objects.filter(email=email).exclude(id=primary_user.id)
|
||||
|
||||
# Get IDs of duplicate accounts to be merged
|
||||
duplicate_account_ids = list(duplicate_user_accounts.values_list('id', flat=True))
|
||||
resource_accesses_to_transfer = ResourceAccess.objects.filter(user_id__in=duplicate_account_ids)
|
||||
|
||||
# Transfer resource access permissions to primary user
|
||||
# This process handles role hierarchy where:
|
||||
# OWNER > ADMIN > MEMBER
|
||||
for resource_access in resource_accesses_to_transfer:
|
||||
|
||||
# Determine if primary user already has access to this resource
|
||||
existing_primary_access = ResourceAccess.objects.filter(
|
||||
user_id=primary_user.id,
|
||||
resource_id=resource_access.resource.id
|
||||
).first()
|
||||
|
||||
if existing_primary_access:
|
||||
# Skip if primary user is already OWNER as it's the highest privilege level
|
||||
# No need to modify or downgrade owner access
|
||||
if existing_primary_access.role == RoleChoices.OWNER:
|
||||
continue
|
||||
|
||||
# Skip if primary user already has the exact same role
|
||||
# No need to update when roles match
|
||||
elif existing_primary_access.role == resource_access.role:
|
||||
continue
|
||||
|
||||
# Skip if new role is MEMBER since user already has base access
|
||||
# All existing access includes at least MEMBER privileges
|
||||
elif resource_access.role == RoleChoices.MEMBER:
|
||||
continue
|
||||
|
||||
# Update the role only if it represents a higher privilege level
|
||||
# Preserves existing access record while updating the role
|
||||
existing_primary_access.role = resource_access.role
|
||||
existing_primary_access.save()
|
||||
else:
|
||||
# Transfer access to primary user
|
||||
resource_access.user_id = primary_user.id
|
||||
resource_access.save()
|
||||
|
||||
# Delete duplicate accounts - CASCADE will automatically remove any untransferred
|
||||
# ResourceAccess records and other related data for these users
|
||||
duplicate_user_accounts.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_recording_recordingaccess_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(merge_duplicate_user_accounts, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-31 21:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_alter_user_language_recording'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='recording',
|
||||
name='unique_active_recording_per_room',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='mode',
|
||||
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], help_text='Defines the kind of worker being called.', max_length=20, verbose_name='Worker kind'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('active', 'Active'), ('initiated', 'Initiated'), ('stopped', 'Stopped'), ('aborted', 'Aborted'), ('saved', 'Saved'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recording',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('status', 'active'), ('status', 'initiated'), _connector='OR'), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-12 10:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_merge_duplicate_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recording',
|
||||
name='mode',
|
||||
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the mode of recording being called.', max_length=20, verbose_name='Recording mode'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-13 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_recording_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_user_full_name_user_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -39,7 +40,7 @@ class RoleChoices(models.TextChoices):
|
||||
|
||||
|
||||
class RecordingStatusChoices(models.TextChoices):
|
||||
"""Recording status choices."""
|
||||
"""Enumeration of possible states for a recording operation."""
|
||||
|
||||
INITIATED = "initiated", _("Initiated")
|
||||
ACTIVE = "active", _("Active")
|
||||
@@ -48,22 +49,28 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
|
||||
@classmethod
|
||||
def is_final_status(cls, status):
|
||||
"""Check if the status is a final status."""
|
||||
return status in [
|
||||
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_error_status(cls, status):
|
||||
"""Check if the status is an error status."""
|
||||
return status in [cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP]
|
||||
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):
|
||||
@@ -135,11 +142,14 @@ 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 login to the admin site
|
||||
# stores the email used by staff users to log in to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email 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)(),
|
||||
@@ -198,18 +208,39 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""
|
||||
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]}"
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""Model to define access control"""
|
||||
|
||||
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
|
||||
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="ResourceAccess",
|
||||
@@ -362,19 +393,94 @@ class Room(Resource):
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
|
||||
# todo - discuss how the path could changed, and we could loose track of file
|
||||
# todo - discuss having constraint to avoid transitioning from a final status to another
|
||||
# todo - discuss persisting extra metadata on the event sent by the storage
|
||||
# todo - discuss inheriting from ResourceAccess model
|
||||
class Recording(BaseModel):
|
||||
"""Model for recordings that take place in a room"""
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
|
||||
creator = models.ForeignKey(
|
||||
def filter_user(self, user):
|
||||
"""Filter accesses for a given user, including both direct and team-based access."""
|
||||
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="recordings",
|
||||
verbose_name=_("Creator"),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
team = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
objects = BaseAccessManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def _get_abilities(self, resource, user):
|
||||
"""
|
||||
Compute and return abilities for a given user taking into account
|
||||
the current state of the object.
|
||||
"""
|
||||
|
||||
roles = get_resource_roles(resource, user)
|
||||
|
||||
is_owner = RoleChoices.OWNER in roles
|
||||
has_privileges = is_owner or RoleChoices.ADMIN in roles
|
||||
|
||||
# Default values for unprivileged users
|
||||
set_role_to = set()
|
||||
can_delete = False
|
||||
|
||||
# Special handling when modifying an owner's access
|
||||
if self.role == RoleChoices.OWNER:
|
||||
# Prevent orphaning the resource
|
||||
can_delete = (
|
||||
is_owner
|
||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
if can_delete:
|
||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
|
||||
elif has_privileges:
|
||||
can_delete = True
|
||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
|
||||
if is_owner:
|
||||
set_role_to.add(RoleChoices.OWNER)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
set_role_to.discard(self.role)
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(roles),
|
||||
"set_role_to": sorted(r.value for r in set_role_to),
|
||||
}
|
||||
|
||||
|
||||
class Recording(BaseModel):
|
||||
"""Model for recordings that take place in a room.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
|
||||
room = models.ForeignKey(
|
||||
Room,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -382,26 +488,27 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Room"),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
max_length=50,
|
||||
choices=RecordingStatusChoices.choices,
|
||||
default=RecordingStatusChoices.INITIATED,
|
||||
)
|
||||
mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=RecordingModeChoices.choices,
|
||||
verbose_name=_("Worker kind"),
|
||||
help_text=_("Defines the kind of worker being called."),
|
||||
)
|
||||
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 and management of recorded data."
|
||||
"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"
|
||||
@@ -411,32 +518,87 @@ class Recording(BaseModel):
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["room"],
|
||||
condition=models.Q(status=RecordingStatusChoices.ACTIVE)
|
||||
| models.Q(status=RecordingStatusChoices.INITIATED),
|
||||
condition=models.Q(
|
||||
status__in=[
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.INITIATED,
|
||||
]
|
||||
),
|
||||
name="unique_initiated_or_active_recording_per_room",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
return f"Recording {self.id} ({self.status})"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user on the recording."""
|
||||
is_creator = user == self.creator
|
||||
is_final_status = RecordingStatusChoices.is_final_status(self.status)
|
||||
|
||||
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_creator and is_final_status,
|
||||
"destroy": is_owner_or_admin and is_final_status,
|
||||
"partial_update": False,
|
||||
"retrieve": is_creator,
|
||||
"stop": is_creator and not is_final_status,
|
||||
"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."""
|
||||
|
||||
is_in_error = RecordingStatusChoices.is_error_status(self.status)
|
||||
is_already_saved = self.status == RecordingStatusChoices.SAVED
|
||||
return self.status in {
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
return not is_in_error and not is_already_saved
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
recording = models.ForeignKey(
|
||||
Recording,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Recording/user relation")
|
||||
verbose_name_plural = _("Recording/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "recording"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_recording_user",
|
||||
violation_error_message=_("This user is already in this recording."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "recording"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_recording_team",
|
||||
violation_error_message=_("This team is already in this recording."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_recording_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the recording access.
|
||||
"""
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
"""Meet event parser classes and exceptions."""
|
||||
|
||||
from .authentication import *
|
||||
from .exceptions import *
|
||||
from .parsers import *
|
||||
"""Meet event parser classes, authentication and exceptions."""
|
||||
|
||||
@@ -12,12 +12,33 @@ 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.
|
||||
"""
|
||||
|
||||
@@ -27,9 +48,17 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||
if not required_token:
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -58,7 +87,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid token"))
|
||||
|
||||
return None
|
||||
return MachineUser(), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception(
|
||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -63,7 +62,6 @@ class EventParser(Protocol):
|
||||
@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
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
"""Meet worker services classes and exceptions."""
|
||||
|
||||
from .exceptions import *
|
||||
from .factories import WorkerServiceFactory
|
||||
from .mediator import WorkerServiceMediator
|
||||
from .services import AudioCompositeEgressService, VideoCompositeEgressService
|
||||
|
||||
# Expose the worker service factory instance at the module level.
|
||||
# This provides a centralized access point and abstracts away the service creation
|
||||
# details from the code using the services.
|
||||
worker_service_provider = WorkerServiceFactory()
|
||||
worker_service_provider.register("transcript", AudioCompositeEgressService)
|
||||
worker_service_provider.register("screen_recording", VideoCompositeEgressService)
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, ClassVar, Dict, Optional, Protocol
|
||||
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__)
|
||||
|
||||
@@ -55,36 +56,20 @@ class WorkerService(Protocol):
|
||||
"""Stop recording for a specified worker."""
|
||||
|
||||
|
||||
class WorkerServiceFactory:
|
||||
"""Factory to instantiate worker services based on a specified mode
|
||||
def get_worker_service(mode: str) -> WorkerService:
|
||||
"""Instantiate a worker service by its mode."""
|
||||
|
||||
This factory currently uses a common configuration (`_default_config`) to initialize all
|
||||
workers. In the future, if workers require different configurations, consider refactoring
|
||||
to a builder pattern. With a builder pattern, specific builders could be registered per
|
||||
WorkerService type, allowing each builder to handle instantiation of the worker with its
|
||||
unique configuration requirements.
|
||||
"""
|
||||
worker_registry: Dict[str, str] = settings.RECORDING_WORKER_CLASSES
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the WorkerServiceFactory with a default configuration and worker registry."""
|
||||
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
|
||||
|
||||
self._worker_service_registry = {}
|
||||
self._default_config = WorkerServiceConfig.from_settings()
|
||||
worker_class: Type[WorkerService] = import_string(worker_class_path)
|
||||
|
||||
def register(self, mode, worker_service: WorkerService):
|
||||
"""Register a worker service for a specific mode."""
|
||||
|
||||
if mode in self._worker_service_registry:
|
||||
raise KeyError(f"Worker service for mode '{mode}' is already registered.")
|
||||
|
||||
self._worker_service_registry[mode] = worker_service
|
||||
|
||||
def create(self, mode: str) -> WorkerService:
|
||||
"""Instantiate a worker service for the specified mode."""
|
||||
|
||||
worker_service_cls = self._worker_service_registry.get(mode)
|
||||
|
||||
if not worker_service_cls:
|
||||
raise ValueError(f"Unknown worker service for mode: {mode}.")
|
||||
|
||||
return worker_service_cls(config=self._default_config)
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
return worker_class(config=config)
|
||||
|
||||
@@ -19,14 +19,9 @@ logger = logging.getLogger(__name__)
|
||||
class WorkerServiceMediator:
|
||||
"""Mediate interactions between a worker service and a recording instance.
|
||||
|
||||
This class avoids direct coupling between the worker service and the Django ORM.
|
||||
It is responsible for updating the recording instance based on the worker service's
|
||||
status and responses. It also encapsulates worker-related errors into more
|
||||
user-friendly higher-level exceptions.
|
||||
|
||||
This class follows the Mediator design pattern to centralize and coordinate
|
||||
the communication between the worker service and the recording instances. It's not only
|
||||
a facade for the worker service, because it adds functionalities to the worker service.
|
||||
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):
|
||||
@@ -37,20 +32,20 @@ class WorkerServiceMediator:
|
||||
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.
|
||||
"""
|
||||
|
||||
# FIXME - no manipulations of room_name should be required
|
||||
room_name = f"{recording.room.id!s}".replace("-", "")
|
||||
|
||||
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:
|
||||
@@ -66,18 +61,19 @@ class WorkerServiceMediator:
|
||||
recording.save()
|
||||
|
||||
logger.info(
|
||||
"Worker started for room %s (worker ID: %s, mode: %s)",
|
||||
"Worker started for room %s (worker ID: %s)",
|
||||
recording.room,
|
||||
recording.worker_id,
|
||||
recording.mode,
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -12,13 +12,13 @@ from .factories import WorkerServiceConfig
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common method to manage and interact with LiveKit egress processes."""
|
||||
"""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, extension):
|
||||
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.
|
||||
"""
|
||||
@@ -44,9 +44,8 @@ class BaseEgressService:
|
||||
|
||||
return response
|
||||
|
||||
def stop(self, worker_id):
|
||||
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.
|
||||
"""
|
||||
@@ -67,7 +66,6 @@ class BaseEgressService:
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
# todo - verify if it's functional
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
@@ -75,7 +73,6 @@ class BaseEgressService:
|
||||
|
||||
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).
|
||||
"""
|
||||
@@ -90,6 +87,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
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")
|
||||
|
||||
@@ -120,6 +118,7 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
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")
|
||||
|
||||
|
||||
@@ -11,32 +11,35 @@ from core.factories import UserFactory
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
def test_authentication_getter_existing_user(monkeypatch):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
If an existing user matches, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
return {"sub": db_user.sub, "email": "some@mail.com"}
|
||||
|
||||
def get_existing_user(*args):
|
||||
return db_user
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
monkeypatch.setattr(
|
||||
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
|
||||
)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
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 the user's info sub, a user should be created.
|
||||
If no user matches, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
@@ -58,16 +61,15 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
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.
|
||||
If no user matches, a user should be created.
|
||||
User's info contains an email, created user's email should be filled.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "meet@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -77,6 +79,34 @@ 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
|
||||
|
||||
@@ -102,3 +132,283 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_empty_sub(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info contains a sub, but it's an empty string."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"test": "123", "sub": ""}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_authentication_get_inactive_user(monkeypatch):
|
||||
"""Test an exception is raised when attempting to authenticate inactive user."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User account is disabled",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
|
||||
def test_finds_user_by_sub(django_assert_num_queries):
|
||||
"""Should return user when found by sub, and email is matching."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user(db_user.sub, db_user.email)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
||||
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
||||
"""Should not return a user when not found by sub, and email is empty."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="foo@mail.com")
|
||||
|
||||
empty_email = ""
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", empty_email)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_finds_user_by_email(django_assert_num_queries, settings):
|
||||
"""Should return user when found by email, and sub is not matching."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
"""Should match email case-insensitively when falling back to email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", "FOO@MAIL.COM")
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
||||
"""Should handle multiple users with same email appropriately."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "foo@mail.com"
|
||||
UserFactory(email=email)
|
||||
UserFactory(email=email) # Second user with same email
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Multiple user accounts share a common email.",
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", email)
|
||||
|
||||
|
||||
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
"""Should not match emails with whitespace."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
"john.doe@example.com", # Fullwidth character in domain
|
||||
"john.doe@еxample.com", # Cyrillic 'е' in domain
|
||||
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
|
||||
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
|
||||
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
|
||||
"""Test email matching security against visually similar but non-ASCII domains.
|
||||
|
||||
Validates that emails with Unicode characters that visually resemble ASCII
|
||||
(homoglyphs) are treated as distinct from their ASCII counterparts for security,
|
||||
per RFC compliance requirements for hostnames.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given_name, usual_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
given_name, usual_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""It should update the email or name fields on the user when they change."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"given_name": given_name,
|
||||
"usual_name": usual_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{given_name:s} {usual_name:s}"
|
||||
assert user.short_name == given_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_info, expected_name",
|
||||
[
|
||||
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
|
||||
(
|
||||
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
|
||||
"John M Doe",
|
||||
),
|
||||
({"family_name": "Doe"}, "Doe"),
|
||||
({"given_name": "", "family_name": ""}, None),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_compute_full_name(user_info, expected_name, settings):
|
||||
"""Test full name computation from OIDC user info fields."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
|
||||
"given_name",
|
||||
"middle_name",
|
||||
"family_name",
|
||||
]
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name(user_info) == expected_name
|
||||
|
||||
|
||||
def test_compute_full_name_no_fields(settings):
|
||||
"""Test full name computation with empty field configuration."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name({"given_name": "John"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims",
|
||||
[
|
||||
{"email": "john.doe@example.com"}, # Same data - no change needed
|
||||
{"email": ""}, # Empty strings should not override
|
||||
{"non_related_field": "foo"}, # Unrelated fields should be ignored
|
||||
{}, # Empty claims should not affect user
|
||||
{"email": None}, # None values should be ignored
|
||||
],
|
||||
)
|
||||
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
||||
"""Test that user attributes remain unchanged when claims don't require updates."""
|
||||
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
klass.update_user_if_needed(user, claims)
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
@@ -4,45 +4,83 @@ Test event authentication.
|
||||
|
||||
# pylint: disable=E1128
|
||||
|
||||
from django.test import RequestFactory, override_settings
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.recording.event.authentication import (
|
||||
MachineUser,
|
||||
StorageEventAuthentication,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_successful_authentication():
|
||||
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"}
|
||||
|
||||
result = StorageEventAuthentication().authenticate(request)
|
||||
assert (
|
||||
result is None
|
||||
) # Authentication successful but returns None as per implementation
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token == "valid-test-token"
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN=None)
|
||||
@pytest.mark.parametrize("add_header", [True, False])
|
||||
def test_disabled_authentication(add_header):
|
||||
"""Test behavior when token is not configured in settings."""
|
||||
# mock_settings.RECORDING_STORAGE_EVENT_TOKEN = ''
|
||||
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("/")
|
||||
if add_header:
|
||||
request.headers = {"Authorization": "Bearer some-token"}
|
||||
request.headers = {"Authorization": "Bearer some-token"}
|
||||
|
||||
result = StorageEventAuthentication().authenticate(request)
|
||||
assert result is None
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_missing_auth_header():
|
||||
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 = {}
|
||||
|
||||
@@ -50,9 +88,9 @@ def test_missing_auth_header():
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_invalid_auth_header_format():
|
||||
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"}
|
||||
|
||||
@@ -60,9 +98,9 @@ def test_invalid_auth_header_format():
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_invalid_token_type():
|
||||
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"}
|
||||
|
||||
@@ -70,9 +108,9 @@ def test_invalid_token_type():
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_invalid_token():
|
||||
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"}
|
||||
|
||||
@@ -80,9 +118,9 @@ def test_invalid_token():
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_malformed_auth_header():
|
||||
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
|
||||
|
||||
@@ -97,9 +135,9 @@ def test_authenticate_header():
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
|
||||
|
||||
@override_settings(RECORDING_STORAGE_EVENT_TOKEN="valid-test-token")
|
||||
def test_multiple_spaces_in_auth_header():
|
||||
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"}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ Test event parsers.
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -245,34 +244,34 @@ def clear_lru_cache():
|
||||
get_parser.cache_clear()
|
||||
|
||||
|
||||
@override_settings(AWS_STORAGE_BUCKET_NAME="test-bucket")
|
||||
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"
|
||||
|
||||
|
||||
@override_settings(AWS_STORAGE_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
|
||||
|
||||
|
||||
@override_settings(AWS_STORAGE_BUCKET_NAME="different-bucket")
|
||||
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."""
|
||||
with override_settings(RECORDING_EVENT_PARSER_CLASS="invalid.parser.path"):
|
||||
with pytest.raises(ImportError):
|
||||
get_parser()
|
||||
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
||||
with pytest.raises(ImportError):
|
||||
get_parser()
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.parsers.import_string")
|
||||
@@ -299,8 +298,11 @@ def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
||||
assert parser1 is parser2
|
||||
|
||||
|
||||
def test_cache_clear_behavior(clear_lru_cache):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import Recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a recording."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording
|
||||
from which they are not related.
|
||||
"""
|
||||
recording = RecordingFactory()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_members():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording
|
||||
from which they are only a member.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_recordings_delete_active(role):
|
||||
"""
|
||||
Authenticated users cannot delete active recordings, even with deletion privileges.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_recordings_delete_final(role):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete an active recording
|
||||
from which they are an admin or owner.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
recording = RecordingFactory(status="saved")
|
||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.count() == 0
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
import operator
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_list_anonymous():
|
||||
"""Anonymous users should not be able to list recordings."""
|
||||
factories.RecordingFactory()
|
||||
response = APIClient().get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
)
|
||||
def test_api_recordings_list_authenticated_direct(role):
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
access = factories.UserRecordingAccessFactory(role=role, user=user)
|
||||
factories.UserRecordingAccessFactory(user=other_user)
|
||||
|
||||
recording = access.recording
|
||||
room = recording.room
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {
|
||||
str(recording.id),
|
||||
}
|
||||
result_ids = {result["id"] for result in results}
|
||||
assert expected_ids == result_ids
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"room": {
|
||||
"id": str(room.id),
|
||||
"is_public": room.is_public,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"status": "initiated",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
|
||||
"""
|
||||
Authenticated users should be able to list recordings they are a
|
||||
owner/administrator/member of via a team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
recordings_team1 = [
|
||||
access.recording
|
||||
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
recordings_team2 = [
|
||||
access.recording
|
||||
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
|
||||
expected_ids = {
|
||||
str(recording.id) for recording in recordings_team1 + recordings_team2
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_recordings_list_pagination(_mock_page_size):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording_ids = [
|
||||
str(access.recording_id)
|
||||
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
recording_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"], "http://testserver/api/v1.0/recordings/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
recording_ids.remove(content["results"][0]["id"])
|
||||
assert recording_ids == []
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_distinct():
|
||||
"""A recording for a room with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = factories.RecordingFactory(users=[user, other_user])
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(recording.id)
|
||||
|
||||
|
||||
def test_api_recordings_list_ordering_default():
|
||||
"""Recordings should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.RecordingFactory.create_batch(5, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check that results are sorted by descending "updated_at" as expected
|
||||
for i in range(4):
|
||||
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: save recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_settings(settings):
|
||||
"""Configure recording-related and storage event Django settings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_parser():
|
||||
"""Mock 'get_parser' factory function."""
|
||||
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
||||
yield mock_parser
|
||||
|
||||
|
||||
def test_save_recording_anonymous(settings, client):
|
||||
"""Anonymous users should not be allowed to save room recordings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_save_recording_wrong_bearer(settings, client):
|
||||
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_save_recording_permission_needed(settings, client):
|
||||
"""Recordings should not be saved when feature is disabled."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of parsing errors in recording event data."""
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
||||
|
||||
|
||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of invalid storage bucket errors in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
||||
|
||||
|
||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||
"""Test handling of unsupported file types in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
||||
"unsupported '.json'"
|
||||
)
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
||||
|
||||
|
||||
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of events for non-existent recordings."""
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found for this event."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
def test_save_recording_non_savable_recording(
|
||||
recording_settings, mock_get_parser, client, status
|
||||
):
|
||||
"""Test that recordings in non-savable states cannot be saved."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
||||
"""Test successful saving of recordings in valid states."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
@@ -5,12 +5,17 @@ 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 WorkerServiceConfig, WorkerServiceFactory
|
||||
from core.recording.worker.factories import (
|
||||
WorkerService,
|
||||
WorkerServiceConfig,
|
||||
get_worker_service,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -41,18 +46,18 @@ def test_settings():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(test_settings):
|
||||
def default_config(test_settings):
|
||||
"""Fixture to provide a WorkerServiceConfig instance"""
|
||||
return WorkerServiceConfig.from_settings()
|
||||
|
||||
|
||||
# Tests
|
||||
def test_config_initialization(config):
|
||||
def test_config_initialization(default_config):
|
||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
||||
assert config.output_folder == "/test/output"
|
||||
assert config.server_configurations == {"server": "test.example.com"}
|
||||
assert config.verify_ssl is True
|
||||
assert config.bucket_args == {
|
||||
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",
|
||||
@@ -62,10 +67,10 @@ def test_config_initialization(config):
|
||||
}
|
||||
|
||||
|
||||
def test_config_immutability(config):
|
||||
def test_config_immutability(default_config):
|
||||
"""Test that config instances are immutable after creation"""
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
config.output_folder = "new/path"
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -88,67 +93,79 @@ def test_config_caching():
|
||||
assert config1 is config2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Fixture to provide a mock WorkerService implementation"""
|
||||
class MockWorkerService(WorkerService):
|
||||
"""Mock worker service for testing."""
|
||||
|
||||
class TestWorkerService:
|
||||
"""Mock WorkerService"""
|
||||
|
||||
hrid = "test-worker"
|
||||
|
||||
def __init__(self, config):
|
||||
"""Mock init"""
|
||||
self.config = config
|
||||
|
||||
def start(self, room_id, recording_id):
|
||||
"""Mock start method"""
|
||||
return f"worker_{room_id}_{recording_id}"
|
||||
|
||||
def stop(self, worker_id):
|
||||
"""Mock stop method"""
|
||||
return f"stopped_{worker_id}"
|
||||
|
||||
return TestWorkerService
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory():
|
||||
"""Fixture to provide a clean WorkerServiceFactory instance"""
|
||||
return WorkerServiceFactory()
|
||||
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_initialization(factory, config):
|
||||
"""Test that factory is properly initialized"""
|
||||
assert factory._worker_service_registry == {}
|
||||
assert isinstance(factory._default_config, WorkerServiceConfig)
|
||||
assert factory._default_config == config
|
||||
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_register_worker_service(factory, mock_worker_service):
|
||||
"""Test registering a new worker service"""
|
||||
factory.register("test", mock_worker_service)
|
||||
assert factory._worker_service_registry["test"] == mock_worker_service
|
||||
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",
|
||||
}
|
||||
|
||||
def test_register_duplicate_worker_service(factory, mock_worker_service):
|
||||
"""Test that registering a duplicate worker service raises an error"""
|
||||
factory.register("test", mock_worker_service)
|
||||
with pytest.raises(KeyError) as exc_info:
|
||||
factory.register("test", mock_worker_service)
|
||||
assert "already registered" in str(exc_info.value)
|
||||
worker = get_worker_service("test_mode")
|
||||
|
||||
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
||||
assert isinstance(worker, MockWorkerService)
|
||||
|
||||
def test_create_worker_service(factory, mock_worker_service):
|
||||
"""Test creating a worker service instance"""
|
||||
factory.register("test", mock_worker_service)
|
||||
worker = factory.create("test")
|
||||
assert isinstance(worker, mock_worker_service)
|
||||
assert worker.hrid == "test-worker"
|
||||
|
||||
|
||||
def test_create_unknown_worker_service(factory):
|
||||
"""Test that creating an unknown worker service raises an error"""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
factory.create("unknown")
|
||||
assert "Unknown worker service" in str(exc_info.value)
|
||||
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)
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_start_recording_success(mediator, mock_worker_service):
|
||||
mediator.start(mock_recording)
|
||||
|
||||
# Verify worker service call
|
||||
expected_room_name = str(mock_recording.room.id).replace("-", "")
|
||||
expected_room_name = str(mock_recording.room.id)
|
||||
mock_worker_service.start.assert_called_once_with(
|
||||
expected_room_name, mock_recording.id
|
||||
)
|
||||
|
||||
@@ -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).replace("-", "")
|
||||
id_no_dashes = str(room.id)
|
||||
|
||||
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}".replace("-", "")
|
||||
expected_name = f"{room.id!s}"
|
||||
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}".replace("-", "")
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -301,6 +301,8 @@ 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,
|
||||
@@ -310,6 +312,8 @@ 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,
|
||||
@@ -318,7 +322,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
expected_name = str(room.id).replace("-", "")
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -374,6 +378,8 @@ 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,
|
||||
@@ -383,6 +389,8 @@ 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,
|
||||
@@ -390,7 +398,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
expected_name = str(room.id).replace("-", "")
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
|
||||
@@ -6,10 +6,7 @@ Test rooms API endpoints in the Meet core app: start recording.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
@@ -26,13 +23,13 @@ def mock_worker_service():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_provider(mock_worker_service):
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.worker_service_provider.create",
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_provider:
|
||||
yield mock_worker_service_provider
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -50,10 +47,11 @@ def test_start_recording_anonymous():
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "standard"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@@ -65,16 +63,18 @@ def test_start_recording_non_owner_and_non_administrator():
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "standard"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=False)
|
||||
def test_start_recording_recording_disabled():
|
||||
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
|
||||
@@ -84,17 +84,19 @@ def test_start_recording_recording_disabled():
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "standard"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_start_recording_missing_mode():
|
||||
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
|
||||
@@ -105,16 +107,17 @@ def test_start_recording_missing_mode():
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/start-recording/", {})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"error": "Recording mode is required."}
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid request."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_start_recording_worker_error(
|
||||
mock_worker_service_provider, mock_worker_manager
|
||||
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
|
||||
@@ -130,12 +133,13 @@ def test_start_recording_worker_error(
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_provider.assert_called_once_with(mode="screen_recording")
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
@@ -145,12 +149,20 @@ def test_start_recording_worker_error(
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
assert recording.creator == user
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_start_recording_success(mock_worker_service_provider, mock_worker_manager):
|
||||
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
|
||||
@@ -163,12 +175,13 @@ def test_start_recording_success(mock_worker_service_provider, mock_worker_manag
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_provider.assert_called_once_with(mode="screen_recording")
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"message": f"Recording successfully started for room {room.slug}"
|
||||
}
|
||||
@@ -176,3 +189,12 @@ def test_start_recording_success(mock_worker_service_provider, mock_worker_manag
|
||||
# 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"
|
||||
@@ -6,10 +6,7 @@ Test rooms API endpoints in the Meet core app: stop recording.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
@@ -26,13 +23,13 @@ def mock_worker_service():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_provider(mock_worker_service):
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.worker_service_provider.create",
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_provider:
|
||||
yield mock_worker_service_provider
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -52,7 +49,7 @@ def test_stop_recording_anonymous():
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.status_code == 401
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
@@ -67,14 +64,16 @@ def test_stop_recording_non_owner_and_non_administrator():
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == 403
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=False)
|
||||
def test_stop_recording_recording_disabled():
|
||||
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
|
||||
@@ -85,15 +84,17 @@ def test_stop_recording_recording_disabled():
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
# Verify no recording exists
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_stop_recording_no_active_recording():
|
||||
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
|
||||
@@ -104,17 +105,23 @@ def test_stop_recording_no_active_recording():
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No active recording found for this room."}
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_stop_recording_worker_error(mock_worker_service_provider, mock_worker_manager):
|
||||
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"
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
@@ -129,10 +136,10 @@ def test_stop_recording_worker_error(mock_worker_service_provider, mock_worker_m
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_provider.assert_called_once_with(mode="screen_recording")
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to stop for room {room.slug}"
|
||||
}
|
||||
@@ -140,13 +147,19 @@ def test_stop_recording_worker_error(mock_worker_service_provider, mock_worker_m
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENABLE=True)
|
||||
def test_stop_recording_success(mock_worker_service_provider, mock_worker_manager):
|
||||
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"
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
@@ -159,10 +172,10 @@ def test_stop_recording_success(mock_worker_service_provider, mock_worker_manage
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_provider.assert_called_once_with(mode="screen_recording")
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
|
||||
|
||||
# Verify the recording still exists
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
Test for the Analytics class.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics import Analytics
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_june_analytics")
|
||||
def _mock_june_analytics():
|
||||
with patch("core.analytics.jAnalytics") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_init_enabled(mock_june_analytics):
|
||||
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
|
||||
analytics = Analytics()
|
||||
assert analytics._enabled is True
|
||||
assert mock_june_analytics.write_key == "test_key"
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_init_disabled():
|
||||
"""Should disable analytics when ANALYTICS_KEY is not set."""
|
||||
analytics = Analytics()
|
||||
assert analytics._enabled is False
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_user(mock_june_analytics):
|
||||
"""Should identify a user with the correct traits when analytics is enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_called_once_with(
|
||||
user_id="12345", traits={"email": "***@example.com"}
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_user_with_traits(mock_june_analytics):
|
||||
"""Should identify a user with additional traits when analytics is enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
|
||||
mock_june_analytics.identify.assert_called_once_with(
|
||||
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_identify_not_enabled(mock_june_analytics):
|
||||
"""Should not call identify when analytics is not enabled."""
|
||||
user = UserFactory(sub="12345", email="user@example.com")
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_no_user(mock_june_analytics):
|
||||
"""Should not call identify when the user is None."""
|
||||
analytics = Analytics()
|
||||
analytics.identify(None)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_identify_anonymous_user(mock_june_analytics):
|
||||
"""Should not call identify when the user is anonymous."""
|
||||
user = AnonymousUser()
|
||||
analytics = Analytics()
|
||||
analytics.identify(user)
|
||||
mock_june_analytics.identify.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
def test_analytics_track_event(mock_june_analytics):
|
||||
"""Should track an event with the correct user and event details when analytics is enabled."""
|
||||
user = UserFactory(sub="12345")
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
user_id="12345", event="test_event", foo="foo"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY=None)
|
||||
def test_analytics_track_event_not_enabled(mock_june_analytics):
|
||||
"""Should not call track when analytics is not enabled."""
|
||||
user = UserFactory(sub="12345")
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
|
||||
mock_june_analytics.track.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
|
||||
"""Should track an event with a random anonymous user ID when the user is None."""
|
||||
analytics = Analytics()
|
||||
analytics.track(None, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||
)
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
@override_settings(ANALYTICS_KEY="test_key")
|
||||
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
|
||||
"""Should track an event with a random anonymous user ID when the user is anonymous."""
|
||||
user = AnonymousUser()
|
||||
analytics = Analytics()
|
||||
analytics.track(user, event="test_event", foo="foo")
|
||||
mock_june_analytics.track.assert_called_once_with(
|
||||
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||
)
|
||||
mock_uuid4.assert_called_once()
|
||||
@@ -96,6 +96,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from core.factories import (
|
||||
RecordingFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -15,7 +20,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_models_recording_str():
|
||||
"""The str representation should be the recording ID."""
|
||||
recording = RecordingFactory()
|
||||
assert str(recording) == str(recording.id)
|
||||
assert str(recording) == f"Recording {recording.id} (initiated)"
|
||||
|
||||
|
||||
def test_models_recording_ordering():
|
||||
@@ -26,14 +31,6 @@ def test_models_recording_ordering():
|
||||
assert recordings[1].created_at >= recordings[2].created_at
|
||||
|
||||
|
||||
def test_models_recording_creator_relationship():
|
||||
"""It should maintain proper relationship with creator."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(creator=user)
|
||||
assert recording.creator == user
|
||||
assert recording in user.recordings.all()
|
||||
|
||||
|
||||
def test_models_recording_room_relationship():
|
||||
"""It should maintain proper relationship with room."""
|
||||
room = RoomFactory()
|
||||
@@ -69,25 +66,36 @@ def test_models_recording_multiple_finished_allowed():
|
||||
|
||||
|
||||
# Test get_abilities method
|
||||
def test_models_recording_get_abilities_creator():
|
||||
"""Test abilities for the recording creator."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||
abilities = recording.get_abilities(recording.creator)
|
||||
|
||||
|
||||
@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, # Creator can always retrieve
|
||||
"retrieve": True, # Privileged users can always retrieve
|
||||
"stop": True, # Not final status, so can stop
|
||||
"update": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_non_creator():
|
||||
"""Test abilities for a user who isn't the creator."""
|
||||
recording = RecordingFactory()
|
||||
other_user = UserFactory()
|
||||
abilities = recording.get_abilities(other_user)
|
||||
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,
|
||||
@@ -98,21 +106,47 @@ def test_models_recording_get_abilities_non_creator():
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_final_status():
|
||||
"""Test abilities when recording is in final status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
abilities = recording.get_abilities(recording.creator)
|
||||
@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, # Final status, creator can destroy
|
||||
"destroy": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"stop": False, # Can't stop when in final status
|
||||
"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)
|
||||
@@ -139,6 +173,12 @@ def test_models_recording_is_savable_already_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
|
||||
|
||||
|
||||
@@ -151,12 +191,6 @@ def test_models_recording_worker_id_optional():
|
||||
assert recording.worker_id == "worker-123"
|
||||
|
||||
|
||||
def test_models_recording_mode_required():
|
||||
"""Recording mode should be required."""
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(mode=None)
|
||||
|
||||
|
||||
def test_models_recording_invalid_status():
|
||||
"""Test that setting an invalid status raises an error."""
|
||||
recording = RecordingFactory()
|
||||
@@ -165,12 +199,6 @@ def test_models_recording_invalid_status():
|
||||
recording.save()
|
||||
|
||||
|
||||
def test_models_recording_invalid_mode():
|
||||
"""Test that setting an invalid mode raises an error."""
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(mode="INVALID_MODE")
|
||||
|
||||
|
||||
def test_models_recording_room_deletion():
|
||||
"""Test that deleting a room cascades to its recordings."""
|
||||
room = RoomFactory()
|
||||
@@ -179,14 +207,6 @@ def test_models_recording_room_deletion():
|
||||
assert not Recording.objects.filter(id=recording.id).exists()
|
||||
|
||||
|
||||
def test_models_recording_creator_deletion():
|
||||
"""Test that deleting a creator cascades to their recordings."""
|
||||
creator = UserFactory()
|
||||
recording = RecordingFactory(creator=creator)
|
||||
creator.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
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Unit tests for the RecordingAccess model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_recording_accesses_str():
|
||||
"""
|
||||
The str representation should include user email, recording ID and role.
|
||||
"""
|
||||
user = factories.UserFactory(email="david.bowman@example.com")
|
||||
access = factories.UserRecordingAccessFactory(
|
||||
role="member",
|
||||
user=user,
|
||||
)
|
||||
assert (
|
||||
str(access)
|
||||
== f"david.bowman@example.com is member in Recording {access.recording.id} (initiated)"
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_unique_user():
|
||||
"""Recording accesses should be unique for a given couple of user and recording."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This user is already in this recording.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(
|
||||
user=access.user, recording=access.recording
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_several_empty_teams():
|
||||
"""A recording can have several recording accesses with an empty team."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
factories.UserRecordingAccessFactory(recording=access.recording)
|
||||
|
||||
|
||||
def test_models_recording_accesses_unique_team():
|
||||
"""Recording accesses should be unique for a given couple of team and recording."""
|
||||
access = factories.TeamRecordingAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This team is already in this recording.",
|
||||
):
|
||||
factories.TeamRecordingAccessFactory(
|
||||
team=access.team, recording=access.recording
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_several_null_users():
|
||||
"""A recording can have several recording accesses with a null user."""
|
||||
access = factories.TeamRecordingAccessFactory()
|
||||
factories.TeamRecordingAccessFactory(recording=access.recording)
|
||||
|
||||
|
||||
def test_models_recording_accesses_user_and_team_set():
|
||||
"""User and team can't both be set on a recording access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(team="my-team")
|
||||
|
||||
|
||||
def test_models_recording_accesses_user_and_team_empty():
|
||||
"""User and team can't both be empty on a recording access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(user=None)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
abilities = access.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
user = factories.UserFactory()
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - for owner
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_self_allowed():
|
||||
"""
|
||||
Check abilities of self access for the owner of a recording when
|
||||
there is more than one owner left.
|
||||
"""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording, role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_self_last():
|
||||
"""
|
||||
Check abilities of self access for the owner of a recording when there is only one owner left.
|
||||
"""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_owner():
|
||||
"""Check abilities of owner access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_administrator():
|
||||
"""Check abilities of administrator access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["member", "owner"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_member():
|
||||
"""Check abilities of member access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "owner"],
|
||||
}
|
||||
|
||||
|
||||
# - for administrator
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_owner():
|
||||
"""Check abilities of owner access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_administrator():
|
||||
"""Check abilities of administrator access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_member():
|
||||
"""Check abilities of member access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator"],
|
||||
}
|
||||
|
||||
|
||||
# - for member
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_owner():
|
||||
"""Check abilities of owner access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_administrator():
|
||||
"""Check abilities of administrator access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_member_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of member access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
@@ -44,12 +44,3 @@ def test_models_users_send_mail_main_missing():
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
assert str(excinfo.value) == "User has no email address."
|
||||
|
||||
|
||||
def test_models_users_email_anonymized():
|
||||
"""The user's email should be anonymized if it exists."""
|
||||
user = factories.UserFactory(email="john.doe@world.com")
|
||||
assert user.email_anonymized == "***@world.com"
|
||||
|
||||
user = factories.UserFactory(email=None)
|
||||
assert user.email_anonymized == ""
|
||||
|
||||
@@ -12,10 +12,10 @@ from core.authentication.urls import urlpatterns as oidc_urls
|
||||
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"
|
||||
)
|
||||
router.register("recording", viewsets.RecordingViewSet, basename="recordings")
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -324,8 +324,14 @@ class Base(Configuration):
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
)
|
||||
OIDC_RP_SIGN_ALGO = values.Value(
|
||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||
@@ -386,6 +392,16 @@ 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 = {
|
||||
@@ -395,15 +411,12 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue(
|
||||
True, environ_name="DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None
|
||||
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
|
||||
True, environ_name="RESOURCE_DEFAULT_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(
|
||||
@@ -415,17 +428,34 @@ class Base(Configuration):
|
||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
||||
{
|
||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
||||
"transcript": "core.recording.worker.services.AudioCompositeEgressService",
|
||||
},
|
||||
environ_name="RECORDING_WORKER_CLASSES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
||||
"core.recording.event.parsers.MinioParser",
|
||||
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
||||
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
@@ -473,8 +503,10 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
@@ -546,8 +578,6 @@ 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"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.7"
|
||||
version = "0.1.12"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,40 +25,39 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.19",
|
||||
"boto3==1.35.76",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.1",
|
||||
"django==5.1.4",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"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",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.2",
|
||||
"PyJWT==2.9.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.14.0",
|
||||
"sentry-sdk==2.19.2",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.7.0",
|
||||
"aiohttp==3.10.10",
|
||||
"livekit-api==0.8.0",
|
||||
"aiohttp==3.11.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,20 +69,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.27.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.7",
|
||||
"pytest-cov==5.0.0",
|
||||
"ipython==8.30.0",
|
||||
"pyfakefs==5.7.2",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.2",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest==8.3.4",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.5",
|
||||
"types-requests==2.32.0.20240914",
|
||||
"ruff==0.8.2",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" data-lk-theme="visio-light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Visio</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,48 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.6.5",
|
||||
"@livekit/components-styles": "1.1.3",
|
||||
"@livekit/components-react": "2.6.9",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.46.1",
|
||||
"@react-aria/toast": "3.0.0-beta.16",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.59.4",
|
||||
"@pandacss/preset-panda": "0.48.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
"@remixicon/react": "4.5.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "23.15.2",
|
||||
"i18next": "24.0.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.5.7",
|
||||
"posthog-js": "1.167.0",
|
||||
"livekit-client": "2.6.3",
|
||||
"posthog-js": "1.188.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.4.0",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.0.0",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.46.1",
|
||||
"@tanstack/eslint-plugin-query": "5.59.2",
|
||||
"@tanstack/react-query-devtools": "5.59.4",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.8.1",
|
||||
"@typescript-eslint/parser": "8.8.1",
|
||||
"@vitejs/plugin-react": "4.3.2",
|
||||
"@pandacss/dev": "0.48.0",
|
||||
"@tanstack/eslint-plugin-query": "5.61.3",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@types/node": "22.9.3",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.15.0",
|
||||
"@typescript-eslint/parser": "8.15.0",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-refresh": "0.4.12",
|
||||
"postcss": "8.4.47",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.8",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.11",
|
||||
"vite-tsconfig-paths": "5.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,46 @@ const config: Config = {
|
||||
exclude: [],
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
globalFontface: {
|
||||
Marianne: [
|
||||
{
|
||||
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
|
||||
fontWeight: 500,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
|
||||
fontWeight: 800,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
breakpoints: {
|
||||
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
||||
xsm: '31.25em', // 500px,
|
||||
sm: '40em', // 640px
|
||||
md: '48em', // 768px
|
||||
lg: '64em', // 1024px
|
||||
@@ -81,10 +116,10 @@ const config: Config = {
|
||||
'80%': { transform: 'rotate(20deg)' },
|
||||
'100%': { transform: 'rotate(0)' },
|
||||
},
|
||||
pulse_mic: {
|
||||
'0%': { color: 'primary', opacity: '1' },
|
||||
'50%': { color: 'primary', opacity: '0.8' },
|
||||
'100%': { color: 'primary', opacity: '1' },
|
||||
pulse_background: {
|
||||
'0%': { opacity: '1' },
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
@@ -92,6 +127,66 @@ const config: Config = {
|
||||
* This way we'll only add the things we need step by step and prevent using lots of differents things.
|
||||
*/
|
||||
...pandaPreset.theme.tokens,
|
||||
colors: defineTokens.colors({
|
||||
...pandaPreset.theme.tokens.colors,
|
||||
primaryDark: {
|
||||
50: { value: '#161622' },
|
||||
100: { value: '#2D2D46' },
|
||||
200: { value: '#43436A' },
|
||||
300: { value: '#5A5A8F' },
|
||||
400: { value: '#7070B3' },
|
||||
500: { value: '#8787D7' },
|
||||
600: { value: '#9D9DDF' },
|
||||
700: { value: '#B3B3E7' },
|
||||
800: { value: '#C9C9EE' },
|
||||
900: { value: '#DFDFF6' },
|
||||
950: { value: '#F5F5FE' },
|
||||
action: { value: '#C1C1FB' },
|
||||
},
|
||||
primary: {
|
||||
50: { value: '#F5F5FE' },
|
||||
100: { value: '#ECECFE' },
|
||||
200: { value: '#E3E3FB' },
|
||||
300: { value: '#CACAFB' },
|
||||
400: { value: '#8585F6' },
|
||||
500: { value: '#6A6AF4' },
|
||||
600: { value: '#313178' },
|
||||
700: { value: '#272747' },
|
||||
800: { value: '#000091' },
|
||||
900: { value: '#21213F' },
|
||||
950: { value: '#1B1B35' },
|
||||
action: { value: '#1212FF' },
|
||||
},
|
||||
greyscale: {
|
||||
'000': { value: '#FFFFFF' },
|
||||
50: { value: '#F6F6F6' },
|
||||
100: { value: '#EEEEEE' },
|
||||
200: { value: '#E5E5E5' },
|
||||
250: { value: '#DDDDDD' },
|
||||
300: { value: '#CECECE' },
|
||||
400: { value: '#929292' },
|
||||
500: { value: '#7C7C7C' },
|
||||
600: { value: '#666666' },
|
||||
700: { value: '#3A3A3A' },
|
||||
750: { value: '#353535' },
|
||||
800: { value: '#2A2A2A' },
|
||||
900: { value: '#242424' },
|
||||
950: { value: '#1E1E1E' },
|
||||
1000: { value: '#161616' },
|
||||
},
|
||||
error: {
|
||||
100: { value: '#261212' },
|
||||
200: { value: '#6C302E' },
|
||||
300: { value: '#983533' },
|
||||
400: { value: '#CA3632' },
|
||||
500: { value: '#EF413D' },
|
||||
600: { value: '#EE6A66' },
|
||||
700: { value: '#F28D8A' },
|
||||
800: { value: '#F6AFAD' },
|
||||
900: { value: '#FAD2D1' },
|
||||
950: { value: '#FFF4F4' },
|
||||
},
|
||||
}),
|
||||
animations: {},
|
||||
blurs: {},
|
||||
/* just directly use values as tokens. This allows us to follow a specific design scale,
|
||||
@@ -193,7 +288,7 @@ const config: Config = {
|
||||
semanticTokens: defineSemanticTokens({
|
||||
colors: {
|
||||
default: {
|
||||
text: { value: '{colors.gray.900}' },
|
||||
text: { value: '{colors.greyscale.1000}' },
|
||||
bg: { value: 'white' },
|
||||
subtle: { value: '{colors.gray.100}' },
|
||||
'subtle-text': { value: '{colors.gray.600}' },
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 4.00098V5.33431H3.33333V12.6676H10.6667V9.33431H12V13.3343C12 13.7025 11.7015 14.001 11.3333 14.001H2.66667C2.29848 14.001 2 13.7025 2 13.3343V4.66764C2 4.29945 2.29848 4.00098 2.66667 4.00098H6.66667ZM14 2.00098V7.33431H12.6667V4.27631L7.47133 9.47231L6.52867 8.52964L11.7233 3.33431H8.66667V2.00098H14Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 21 KiB |
@@ -30,7 +30,7 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -11,6 +12,10 @@ export interface ApiConfig {
|
||||
id: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
|
After Width: | Height: | Size: 651 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 599 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="448" height="172" viewBox="0 0 448 172" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 65.6951C5 68.832 5 72.6949 5 79.1656V94.7307C5 102.759 5 106.773 6.16756 110.366C7.20062 113.546 8.89041 116.472 11.1274 118.957C13.6555 121.765 17.1318 123.772 24.0842 127.786L37.564 135.568C43.7169 139.121 47.1472 141.101 50.4165 142.054V94.0288C50.4165 91.6281 49.1052 89.4192 46.9976 88.2696L5.61135 65.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 72.7489V97.7898C118.313 101.252 119.818 104.544 122.436 106.809L140.459 122.406C141.513 123.298 142.608 124.028 143.744 124.596C144.92 125.123 146.056 125.387 147.151 125.387C149.503 125.387 151.389 124.616 152.809 123.075C154.269 121.493 154.999 119.445 154.999 116.93V53.6577C154.999 51.1431 154.269 49.1151 152.809 47.5738C151.389 45.992 149.503 45.2011 147.151 45.2011C146.056 45.2011 144.92 45.4647 143.744 45.992C142.608 46.5193 141.513 47.2494 140.459 48.1822L122.45 63.7172C119.823 65.983 118.313 69.28 118.313 72.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 56.7522C11.1333 56.4788 10.6078 56.2937 10.0757 56.1915C10.4114 55.7628 10.7622 55.3452 11.1276 54.9394C13.6558 52.1315 17.132 50.1245 24.0845 46.1105L37.5643 38.3279C44.5167 34.3139 47.993 32.3069 51.6887 31.5213C54.9587 30.8262 58.3383 30.8262 61.6083 31.5213C65.304 32.3069 68.7803 34.3139 75.7327 38.3279L89.0535 46.0187C89.1066 46.0492 89.1597 46.0798 89.2129 46.1105C96.1653 50.1245 99.6416 52.1316 102.17 54.9394C104.407 57.4238 106.096 60.3506 107.13 63.5301C108.297 67.1235 108.297 71.1375 108.297 79.1656V94.7307C108.297 102.759 108.297 106.773 107.13 110.366C106.096 113.546 104.407 116.473 102.17 118.957C99.6416 121.765 96.1655 123.772 89.2133 127.785C89.1537 127.82 89.0936 127.855 89.0341 127.889L75.7327 135.568C68.7803 139.582 65.304 141.589 61.6083 142.375C61.4564 142.407 61.3043 142.438 61.1519 142.467L61.1519 94.0288C61.1519 87.6997 57.6948 81.8761 52.1386 78.8455L11.6345 56.7522Z" fill="#000091"/>
|
||||
<path d="M193.72 45.7333H211.035L234.771 108.456L258.507 45.7333H275.821L245.435 126H224.107L193.72 45.7333ZM289.019 58.1173C283.859 58.1173 279.501 53.76 279.501 48.6C279.501 43.44 283.859 39.0827 289.019 39.0827C294.179 39.0827 298.421 43.44 298.421 48.6C298.421 53.76 294.179 58.1173 289.019 58.1173ZM281.68 126V68.208H296.243V126H281.68ZM303.29 117.629L312.922 108.915C316.477 113.387 320.72 116.597 326.224 116.597C330.925 116.597 333.333 113.845 333.333 110.405C333.333 100.315 305.698 104.099 305.698 83.8027C305.698 73.5973 314.298 65.9147 326.338 65.9147C335.168 65.9147 343.194 70.1573 347.322 75.776L337.69 84.2613C334.709 80.592 330.925 77.6107 326.453 77.6107C321.866 77.6107 319.688 80.1333 319.688 83.1147C319.688 92.976 347.322 89.536 347.322 109.488C347.093 121.643 337.346 128.293 326.453 128.293C316.133 128.293 308.794 124.165 303.29 117.629ZM363.494 58.1173C358.334 58.1173 353.977 53.76 353.977 48.6C353.977 43.44 358.334 39.0827 363.494 39.0827C368.654 39.0827 372.897 43.44 372.897 48.6C372.897 53.76 368.654 58.1173 363.494 58.1173ZM356.155 126V68.208H370.718V126H356.155ZM411.115 65.9147C429.92 65.9147 442.763 79.7893 442.763 97.104C442.763 114.419 429.92 128.293 411.115 128.293C392.309 128.293 379.467 114.419 379.467 97.104C379.467 79.7893 392.309 65.9147 411.115 65.9147ZM411.344 114.533C420.632 114.533 427.627 107.08 427.627 97.104C427.627 87.0133 420.632 79.6747 411.344 79.6747C401.712 79.6747 394.603 87.0133 394.603 97.104C394.603 107.195 401.712 114.533 411.344 114.533Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,25 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LinkButton } from '@/primitives'
|
||||
|
||||
export const Feedback = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
variant="success"
|
||||
target="_blank"
|
||||
>
|
||||
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
{t('feedbackAlert')}
|
||||
<RiExternalLinkLine
|
||||
size={16}
|
||||
className={css({ marginLeft: 0.5 })}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
display: { base: 'none', sm: 'flex' },
|
||||
justifyContent: 'center',
|
||||
padding: '0.5rem 0',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={20} />
|
||||
<Text as="p">{t('feedback.context')}</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FORM} target="_blank">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Center } from '@/styled-system/jsx'
|
||||
export const LoadingScreen = ({
|
||||
delay = 500,
|
||||
header = undefined,
|
||||
footer = undefined,
|
||||
layout = 'centered',
|
||||
}: {
|
||||
delay?: number
|
||||
@@ -15,7 +16,7 @@ export const LoadingScreen = ({
|
||||
|
||||
return (
|
||||
<DelayedRender delay={delay}>
|
||||
<Screen layout={layout} header={header}>
|
||||
<Screen layout={layout} header={header} footer={footer}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const QueryAware = ({
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return <LoadingScreen header={undefined} />
|
||||
return <LoadingScreen header={undefined} footer={undefined} />
|
||||
}
|
||||
|
||||
return children
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SoundTester = () => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondaryText"
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
export const useIsAnalyticsEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.analytics?.id
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
last_name: string
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ export const UserAware = ({ children }: { children: React.ReactNode }) => {
|
||||
return isLoggedIn !== undefined ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen header={false} delay={1000} />
|
||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import firstSlide from '@/assets/intro-slider/1_solo.png'
|
||||
import secondSlide from '@/assets/intro-slider/2_multiple.png'
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, LinkButton } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// todo - extract in a proper env variable
|
||||
const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
width: 'fit-content',
|
||||
marginBottom: 0,
|
||||
fontSize: '1.5rem',
|
||||
marginTop: '0.75rem',
|
||||
lineHeight: '2rem',
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
},
|
||||
})
|
||||
|
||||
const Body = styled('p', {
|
||||
base: {
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
lineHeight: '1.2rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Image = styled('img', {
|
||||
base: {
|
||||
maxHeight: '362px',
|
||||
height: '100%',
|
||||
width: 'fit-content',
|
||||
},
|
||||
})
|
||||
|
||||
const Dot = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
height: '.375rem',
|
||||
margin: '0 .25rem',
|
||||
width: '.375rem',
|
||||
},
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
backgroundColor: '#000091',
|
||||
},
|
||||
false: {
|
||||
backgroundColor: '#CACAFB',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonContainer = styled('div', {
|
||||
base: {
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonVerticalCenter = styled('div', {
|
||||
base: {
|
||||
marginTop: '13.3125rem',
|
||||
transform: 'translateY(-50%)',
|
||||
},
|
||||
})
|
||||
|
||||
const SlideContainer = styled('div', {
|
||||
base: {
|
||||
alignItems: 'stretch',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
},
|
||||
})
|
||||
|
||||
const Slide = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'start',
|
||||
minHeight: { base: 'none', xsm: '550px' },
|
||||
minWidth: { base: 'none', xsm: '200px' },
|
||||
width: { base: '100%', xsm: '22.625rem' },
|
||||
},
|
||||
variants: {
|
||||
visible: {
|
||||
true: {
|
||||
visibility: 'visible',
|
||||
position: 'static',
|
||||
},
|
||||
false: {
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
visible: false,
|
||||
},
|
||||
})
|
||||
|
||||
const TextAnimation = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
variants: {
|
||||
visible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'none',
|
||||
transition: 'opacity ease-in .3s, transform ease-in .3s',
|
||||
},
|
||||
false: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(-30%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Slide = {
|
||||
key: string
|
||||
img: string
|
||||
isAvailableInBeta?: boolean
|
||||
}
|
||||
|
||||
// todo - optimize how images are imported
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
key: 'slide1',
|
||||
img: firstSlide,
|
||||
},
|
||||
{
|
||||
key: 'slide2',
|
||||
img: secondSlide,
|
||||
},
|
||||
{
|
||||
key: 'slide3',
|
||||
img: thirdSlide,
|
||||
isAvailableInBeta: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const IntroSlider = () => {
|
||||
const [slideIndex, setSlideIndex] = useState(0)
|
||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||
const NUMBER_SLIDES = SLIDES.length
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('previous.label')}
|
||||
tooltip={t('previous.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||
isDisabled={slideIndex == 0}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={BETA_USERS_FORM_URL}
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
style={{ marginTop: '1rem', width: 'fit-content' }}
|
||||
>
|
||||
{t('beta.text')}
|
||||
</LinkButton>
|
||||
)}
|
||||
</TextAnimation>
|
||||
</Slide>
|
||||
))}
|
||||
</SlideContainer>
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
aria-label={t('next.label')}
|
||||
tooltip={t('next.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.5rem',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{SLIDES.map((_, index) => (
|
||||
<Dot key={index} selected={index == slideIndex} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { A, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MANIFEST_LINK =
|
||||
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
|
||||
|
||||
export const MoreLink = () => {
|
||||
const { t } = useTranslation('home')
|
||||
|
||||
return (
|
||||
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
||||
<A
|
||||
href={MANIFEST_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('moreLinkLabel')}
|
||||
>
|
||||
{t('moreLink')}
|
||||
</A>{' '}
|
||||
{t('moreAbout')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,149 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { styled } 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'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
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 { useState } from 'react'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
alignItems: 'center',
|
||||
margin: 'auto',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: '100%',
|
||||
justifyContent: 'normal',
|
||||
padding: '0 1rem',
|
||||
width: 'calc(100% - 2rem)',
|
||||
'@media(prefers-reduced-motion: reduce)': {
|
||||
opacity: 1,
|
||||
},
|
||||
'@media(prefers-reduced-motion: no-preference)': {
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
},
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexBasis: 'auto',
|
||||
flexShrink: 0,
|
||||
maxWidth: '38rem',
|
||||
width: '100%',
|
||||
padding: '1rem 3%',
|
||||
marginTop: 'auto',
|
||||
lg: {
|
||||
margin: 0,
|
||||
textAlign: 'left',
|
||||
alignItems: 'flex-start',
|
||||
flexShrink: '1',
|
||||
flexBasis: '36rem',
|
||||
maxWidth: '36rem',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RightColumn = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '1rem 3%',
|
||||
marginBottom: 'auto',
|
||||
flexBasis: 'auto',
|
||||
flexShrink: 0,
|
||||
maxWidth: '39rem',
|
||||
lg: {
|
||||
margin: 0,
|
||||
flexBasis: '45%',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Separator = styled('div', {
|
||||
base: {
|
||||
borderBottom: '1px solid',
|
||||
borderColor: '#747775',
|
||||
marginTop: '2.5rem',
|
||||
maxWidth: '30rem',
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
const Heading = styled('h1', {
|
||||
base: {
|
||||
fontWeight: '500',
|
||||
fontStyle: 'normal',
|
||||
fontStretch: 'normal',
|
||||
fontOpticalSizing: 'auto',
|
||||
marginBottom: 0,
|
||||
paddingBottom: '0.75rem',
|
||||
fontSize: '2.3rem',
|
||||
lineHeight: '2.5rem',
|
||||
letterSpacing: '0',
|
||||
xsm: {
|
||||
fontSize: '3rem',
|
||||
lineHeight: '3.2rem',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const IntroText = styled('div', {
|
||||
base: {
|
||||
marginBottom: '3rem',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1.8rem',
|
||||
textWrap: 'pretty',
|
||||
maxWidth: '32rem',
|
||||
},
|
||||
})
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
@@ -30,72 +159,93 @@ export const Home = () => {
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
<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')}
|
||||
<Columns>
|
||||
<LeftColumn>
|
||||
<Heading>{t('heading')}</Heading>
|
||||
<IntroText>{t('intro')}</IntroText>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexDirection: { base: 'column', xsm: 'row' },
|
||||
alignItems: { base: 'center', xsm: 'items-start' },
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
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={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
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="secondary"
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
>
|
||||
{t('joinMeeting')}
|
||||
</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 />
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="primary"
|
||||
outline
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
>
|
||||
{t('joinMeeting')}
|
||||
</Button>
|
||||
<JoinMeetingDialog />
|
||||
</DialogTrigger>
|
||||
</HStack>
|
||||
</Centered>
|
||||
<JoinMeetingDialog />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<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>
|
||||
<LaterMeetingDialog
|
||||
roomId={laterRoomId || ''}
|
||||
onOpenChange={() => setLaterRoomId(null)}
|
||||
|
||||
@@ -16,11 +16,11 @@ export const MainNotificationToast = () => {
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
triggerNotificationSound(NotificationType.Joined)
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Joined,
|
||||
type: NotificationType.ParticipantJoined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.Raised
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
)
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.Raised)
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Raised,
|
||||
type: NotificationType.HandRaised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export enum NotificationType {
|
||||
Joined = 'joined',
|
||||
Default = 'default',
|
||||
Raised = 'raised',
|
||||
Lowered = 'lowered',
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
// todo - implement message received notification
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
return (
|
||||
<div {...regionProps} ref={ref} className="toast-region">
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.Joined) {
|
||||
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.Raised) {
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import useSound from 'use-sound'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
joined: [0, 1150],
|
||||
raised: [1400, 180],
|
||||
message: [1580, 300],
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
messageReceived: [1580, 300],
|
||||
waiting: [2039, 710],
|
||||
success: [2740, 1304],
|
||||
},
|
||||
volume: notificationsSnap.soundNotificationVolume,
|
||||
})
|
||||
const triggerNotificationSound = (type: string) => {
|
||||
play({ id: type })
|
||||
const triggerNotificationSound = (type: NotificationType) => {
|
||||
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
|
||||
if (isSoundEnabled) play({ id: type })
|
||||
}
|
||||
return { triggerNotificationSound }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type ApiRoom = {
|
||||
name: string
|
||||
slug: string
|
||||
is_public: boolean
|
||||
is_administrable: boolean
|
||||
livekit?: {
|
||||
url: string
|
||||
room: string
|
||||
|
||||
@@ -3,14 +3,25 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface StartRecordingParams {
|
||||
slug: string
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
const startRecording = ({ slug }: StartRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${slug}/start-recording/`, {
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
id,
|
||||
mode = RecordingMode.Transcript,
|
||||
}: StartRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-recording/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({
|
||||
mode: mode,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||