mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9dedb91b | |||
| 3da3641a19 | |||
| 21626b4c4c | |||
| f16cedf5a5 | |||
| 00e4679dc5 |
@@ -1,5 +1,4 @@
|
|||||||
name: Docker Hub Workflow
|
name: Docker Hub Workflow
|
||||||
run-name: Docker Hub Workflow
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -49,15 +48,9 @@ jobs:
|
|||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||||
-
|
|
||||||
name: Run trivy scan
|
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
|
||||||
with:
|
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
|
||||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
@@ -99,15 +92,9 @@ jobs:
|
|||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||||
-
|
|
||||||
name: Run trivy scan
|
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
|
||||||
with:
|
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
|
||||||
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Helmfile lint
|
|
||||||
run-name: Helmfile lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
helmfile-lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: ghcr.io/helmfile/helmfile:latest
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
uses: numerique-gouv/action-helmfile-lint@main
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
|
||||||
helmfile-src: "src/helm"
|
|
||||||
repositories: "meet,secrets"
|
|
||||||
@@ -8,5 +8,3 @@ creation_rules:
|
|||||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
||||||
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
||||||
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
||||||
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
|
|
||||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
|
||||||
|
|||||||
+31
-22
@@ -1,17 +1,18 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.12.6-alpine3.20 AS base
|
FROM python:3.10-slim-bullseye as base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apt-get update && \
|
||||||
apk upgrade
|
apt-get -y upgrade && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base as back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:20 as mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -34,12 +35,15 @@ RUN yarn install --frozen-lockfile && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
FROM base AS link-collector
|
FROM base as link-collector
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
RUN apk add \
|
# Install libpangocairo & rdfind
|
||||||
pango \
|
RUN apt-get update && \
|
||||||
rdfind
|
apt-get install -y \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
rdfind && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy installed python dependencies
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /install /usr/local
|
COPY --from=back-builder /install /usr/local
|
||||||
@@ -58,18 +62,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
|||||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
||||||
|
|
||||||
# ---- Core application image ----
|
# ---- Core application image ----
|
||||||
FROM base AS core
|
FROM base as core
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apk add \
|
# Install required system libs
|
||||||
gettext \
|
RUN apt-get update && \
|
||||||
cairo \
|
apt-get install -y \
|
||||||
libffi-dev \
|
gettext \
|
||||||
gdk-pixbuf \
|
libcairo2 \
|
||||||
pango \
|
libffi-dev \
|
||||||
shared-mime-info
|
libgdk-pixbuf2.0-0 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
shared-mime-info && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy entrypoint
|
# Copy entrypoint
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
@@ -93,13 +100,15 @@ WORKDIR /app
|
|||||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||||
|
|
||||||
# ---- Development image ----
|
# ---- Development image ----
|
||||||
FROM core AS backend-development
|
FROM core as backend-development
|
||||||
|
|
||||||
# Switch back to the root user to install development dependencies
|
# Switch back to the root user to install development dependencies
|
||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apt-get update && \
|
||||||
|
apt-get install -y postgresql-client && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Uninstall Meet and re-install it in editable mode along with development
|
# Uninstall Meet and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
@@ -119,7 +128,7 @@ ENV DB_HOST=postgresql \
|
|||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core AS backend-production
|
FROM core as backend-production
|
||||||
|
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
|
|||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt: ## start the kubernetes cluster using kind
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
kubectl config set-context --current --namespace=meet
|
|
||||||
tilt up -f ./bin/Tiltfile
|
tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -97,41 +97,6 @@ data:
|
|||||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<EOF | kubectl apply -f -
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: coredns
|
|
||||||
namespace: kube-system
|
|
||||||
data:
|
|
||||||
Corefile: |
|
|
||||||
.:53 {
|
|
||||||
errors
|
|
||||||
health {
|
|
||||||
lameduck 5s
|
|
||||||
}
|
|
||||||
ready
|
|
||||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
|
||||||
pods insecure
|
|
||||||
fallthrough in-addr.arpa ip6.arpa
|
|
||||||
ttl 30
|
|
||||||
}
|
|
||||||
prometheus :9153
|
|
||||||
forward . /etc/resolv.conf {
|
|
||||||
max_concurrent 1000
|
|
||||||
}
|
|
||||||
rewrite stop {
|
|
||||||
name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto
|
|
||||||
}
|
|
||||||
cache 30
|
|
||||||
loop
|
|
||||||
reload
|
|
||||||
loadbalance
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
kubectl -n kube-system rollout restart deployments/coredns
|
|
||||||
|
|
||||||
echo "6. Install ingress-nginx"
|
echo "6. Install ingress-nginx"
|
||||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
HELMFILE=src/helm/helmfile.yaml
|
|
||||||
|
|
||||||
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
|
|
||||||
|
|
||||||
for env in $environments; do
|
|
||||||
echo "################### $env lint ###################"
|
|
||||||
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
|
||||||
echo -e "\n"
|
|
||||||
done
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgresql:
|
postgresql:
|
||||||
+1
-3
@@ -13,9 +13,7 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": [
|
"matchPackageNames": []
|
||||||
"eslint"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule secrets updated: 8ef9f4513a...f5fbc16e6e
@@ -447,10 +447,10 @@ max-bool-expr=5
|
|||||||
max-branches=12
|
max-branches=12
|
||||||
|
|
||||||
# Maximum number of locals for function / method body
|
# Maximum number of locals for function / method body
|
||||||
max-locals=20
|
max-locals=15
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
# Maximum number of parents for a class (see R0901).
|
||||||
max-parents=10
|
max-parents=7
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
# Maximum number of public methods for a class (see R0904).
|
||||||
max-public-methods=20
|
max-public-methods=20
|
||||||
|
|||||||
@@ -22,19 +22,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||||
_("Personal info"),
|
|
||||||
{
|
|
||||||
"fields": (
|
|
||||||
"sub",
|
|
||||||
"email",
|
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"language",
|
|
||||||
"timezone",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
_("Permissions"),
|
_("Permissions"),
|
||||||
{
|
{
|
||||||
@@ -64,8 +52,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"sub",
|
"sub",
|
||||||
"admin_email",
|
"admin_email",
|
||||||
"email",
|
"email",
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_staff",
|
"is_staff",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
@@ -74,24 +60,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||||
ordering = (
|
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||||
"is_active",
|
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||||
"-is_superuser",
|
search_fields = ("id", "sub", "admin_email", "email")
|
||||||
"-is_staff",
|
|
||||||
"-is_device",
|
|
||||||
"-updated_at",
|
|
||||||
"full_name",
|
|
||||||
)
|
|
||||||
readonly_fields = (
|
|
||||||
"id",
|
|
||||||
"sub",
|
|
||||||
"email",
|
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
)
|
|
||||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessInline(admin.TabularInline):
|
class ResourceAccessInline(admin.TabularInline):
|
||||||
@@ -106,18 +77,3 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
"""Room admin interface declaration."""
|
"""Room admin interface declaration."""
|
||||||
|
|
||||||
inlines = (ResourceAccessInline,)
|
inlines = (ResourceAccessInline,)
|
||||||
|
|
||||||
|
|
||||||
class RecordingAccessInline(admin.TabularInline):
|
|
||||||
"""Inline admin class for recording accesses."""
|
|
||||||
|
|
||||||
model = models.RecordingAccess
|
|
||||||
extra = 0
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Recording)
|
|
||||||
class RecordingAdmin(admin.ModelAdmin):
|
|
||||||
"""Recording admin interface declaration."""
|
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
|
||||||
list_display = ("id", "status", "room", "created_at", "worker_id")
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Meet analytics class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from june import analytics as jAnalytics
|
||||||
|
|
||||||
|
|
||||||
|
class Analytics:
|
||||||
|
"""Analytics integration
|
||||||
|
|
||||||
|
This class wraps the June analytics code to avoid coupling our code directly
|
||||||
|
with this third-party library. By doing so, we create a generic interface
|
||||||
|
for analytics that can be easily modified or replaced in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
key = getattr(settings, "ANALYTICS_KEY", None)
|
||||||
|
|
||||||
|
if key is not None:
|
||||||
|
jAnalytics.write_key = key
|
||||||
|
|
||||||
|
self._enabled = key is not None
|
||||||
|
|
||||||
|
def _is_anonymous_user(self, user):
|
||||||
|
"""Check if the user is anonymous."""
|
||||||
|
return user is None or user.is_anonymous
|
||||||
|
|
||||||
|
def identify(self, user, **kwargs):
|
||||||
|
"""Identify a user"""
|
||||||
|
|
||||||
|
if self._is_anonymous_user(user) or not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
traits = kwargs.pop("traits", {})
|
||||||
|
traits.update({"email": user.email_anonymized})
|
||||||
|
|
||||||
|
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
|
||||||
|
|
||||||
|
def track(self, user, **kwargs):
|
||||||
|
"""Track an event"""
|
||||||
|
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = {}
|
||||||
|
if self._is_anonymous_user(user):
|
||||||
|
event_data["anonymous_id"] = str(uuid.uuid4())
|
||||||
|
else:
|
||||||
|
event_data["user_id"] = user.sub
|
||||||
|
|
||||||
|
jAnalytics.track(**event_data, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
analytics = Analytics()
|
||||||
@@ -37,10 +37,6 @@ def get_frontend_configuration(request):
|
|||||||
"""Returns the frontend configuration dict as configured in settings."""
|
"""Returns the frontend configuration dict as configured in settings."""
|
||||||
frontend_configuration = {
|
frontend_configuration = {
|
||||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||||
"recording": {
|
|
||||||
"is_enabled": settings.RECORDING_ENABLE,
|
|
||||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Permission handlers for the Meet core app."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
@@ -67,11 +65,15 @@ class RoomPermissions(permissions.BasePermission):
|
|||||||
return obj.is_administrator(user)
|
return obj.is_administrator(user)
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessPermission(IsAuthenticated):
|
class ResourceAccessPermission(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions for a room that can only be updated by room administrators.
|
Permissions for a room that can only be updated by room administrators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Only allow authenticated users."""
|
||||||
|
return request.user.is_authenticated
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""
|
"""
|
||||||
Check that the logged-in user is administrator of the linked room.
|
Check that the logged-in user is administrator of the linked room.
|
||||||
@@ -81,41 +83,3 @@ class ResourceAccessPermission(IsAuthenticated):
|
|||||||
return obj.user == user
|
return obj.user == user
|
||||||
|
|
||||||
return obj.resource.is_administrator(user)
|
return obj.resource.is_administrator(user)
|
||||||
|
|
||||||
|
|
||||||
class HasAbilityPermission(IsAuthenticated):
|
|
||||||
"""Permission class for access objects."""
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""Check permission for a given object."""
|
|
||||||
return obj.get_abilities(request.user).get(view.action, False)
|
|
||||||
|
|
||||||
|
|
||||||
class HasPrivilegesOnRoom(IsAuthenticated):
|
|
||||||
"""Check if user has privileges on a given room."""
|
|
||||||
|
|
||||||
message = "You must have privileges to start a recording."
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""Determine if user has privileges on room."""
|
|
||||||
return obj.is_owner(request.user) or obj.is_administrator(request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class IsRecordingEnabled(permissions.BasePermission):
|
|
||||||
"""Check if the recording feature is enabled."""
|
|
||||||
|
|
||||||
message = "Access denied, recording is disabled."
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Determine if access is allowed based on settings."""
|
|
||||||
return settings.RECORDING_ENABLE
|
|
||||||
|
|
||||||
|
|
||||||
class IsStorageEventEnabled(permissions.BasePermission):
|
|
||||||
"""Check if the storage event feature is enabled."""
|
|
||||||
|
|
||||||
message = "Access denied, storage event is disabled."
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Determine if access is allowed based on settings."""
|
|
||||||
return settings.RECORDING_STORAGE_EVENT_ENABLE
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name"]
|
fields = ["id", "email"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email"]
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessSerializerMixin:
|
class ResourceAccessSerializerMixin:
|
||||||
@@ -87,15 +87,6 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
|
|||||||
user = UserSerializer(read_only=True)
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class ListRoomSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serialize Room model for a list API endpoint."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.Room
|
|
||||||
fields = ["id", "name", "slug", "is_public"]
|
|
||||||
read_only_fields = ["id", "slug"]
|
|
||||||
|
|
||||||
|
|
||||||
class RoomSerializer(serializers.ModelSerializer):
|
class RoomSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize Room model for the API."""
|
"""Serialize Room model for the API."""
|
||||||
|
|
||||||
@@ -130,7 +121,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
del output["configuration"]
|
del output["configuration"]
|
||||||
|
|
||||||
if role is not None or instance.is_public:
|
if role is not None or instance.is_public:
|
||||||
slug = f"{instance.id!s}"
|
slug = f"{instance.id!s}".replace("-", "")
|
||||||
|
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
@@ -144,36 +136,3 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
output["is_administrable"] = is_admin
|
output["is_administrable"] = is_admin
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
class RecordingSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serialize Recording for the API."""
|
|
||||||
|
|
||||||
room = ListRoomSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.Recording
|
|
||||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
|
||||||
read_only_fields = fields
|
|
||||||
|
|
||||||
|
|
||||||
class StartRecordingSerializer(serializers.Serializer):
|
|
||||||
"""Validate start recording requests."""
|
|
||||||
|
|
||||||
mode = serializers.ChoiceField(
|
|
||||||
choices=models.RecordingModeChoices.choices,
|
|
||||||
required=True,
|
|
||||||
error_messages={
|
|
||||||
"required": "Recording mode is required.",
|
|
||||||
"invalid_choice": "Invalid recording mode. Choose between "
|
|
||||||
"screen_recording or transcript.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
"""Not implemented as this is a validation-only serializer."""
|
|
||||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
"""Not implemented as this is a validation-only serializer."""
|
|
||||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -15,41 +14,17 @@ from rest_framework import (
|
|||||||
pagination,
|
pagination,
|
||||||
viewsets,
|
viewsets,
|
||||||
)
|
)
|
||||||
from rest_framework import (
|
|
||||||
exceptions as drf_exceptions,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
from rest_framework import (
|
|
||||||
status as drf_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.recording.event.authentication import StorageEventAuthentication
|
|
||||||
from core.recording.event.exceptions import (
|
|
||||||
InvalidBucketError,
|
|
||||||
InvalidFileTypeError,
|
|
||||||
ParsingEventDataError,
|
|
||||||
)
|
|
||||||
from core.recording.event.parsers import get_parser
|
|
||||||
from core.recording.worker.exceptions import (
|
|
||||||
RecordingStartError,
|
|
||||||
RecordingStopError,
|
|
||||||
)
|
|
||||||
from core.recording.worker.factories import (
|
|
||||||
get_worker_service,
|
|
||||||
)
|
|
||||||
from core.recording.worker.mediator import (
|
|
||||||
WorkerServiceMediator,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -211,6 +186,13 @@ class RoomViewSet(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
|
||||||
|
analytics.track(
|
||||||
|
user=self.request.user,
|
||||||
|
event="Get Room",
|
||||||
|
properties={"slug": instance.slug},
|
||||||
|
)
|
||||||
|
|
||||||
except Http404:
|
except Http404:
|
||||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||||
raise
|
raise
|
||||||
@@ -259,87 +241,12 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
@decorators.action(
|
analytics.track(
|
||||||
detail=True,
|
user=self.request.user,
|
||||||
methods=["post"],
|
event="Create Room",
|
||||||
url_path="start-recording",
|
properties={
|
||||||
permission_classes=[
|
"slug": room.slug,
|
||||||
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}."}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -386,71 +293,3 @@ class ResourceAccessViewSet(
|
|||||||
permission_classes = [permissions.ResourceAccessPermission]
|
permission_classes = [permissions.ResourceAccessPermission]
|
||||||
queryset = models.ResourceAccess.objects.all()
|
queryset = models.ResourceAccess.objects.all()
|
||||||
serializer_class = serializers.ResourceAccessSerializer
|
serializer_class = serializers.ResourceAccessSerializer
|
||||||
|
|
||||||
|
|
||||||
class RecordingViewSet(
|
|
||||||
mixins.DestroyModelMixin,
|
|
||||||
mixins.ListModelMixin,
|
|
||||||
viewsets.GenericViewSet,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
API endpoints to access and perform actions on recordings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pagination_class = Pagination
|
|
||||||
permission_classes = [permissions.HasAbilityPermission]
|
|
||||||
queryset = models.Recording.objects.all()
|
|
||||||
serializer_class = serializers.RecordingSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
"""Restrict recordings to the user's ones."""
|
|
||||||
user = self.request.user
|
|
||||||
return (
|
|
||||||
super()
|
|
||||||
.get_queryset()
|
|
||||||
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="storage-hook",
|
|
||||||
authentication_classes=[StorageEventAuthentication],
|
|
||||||
permission_classes=[permissions.IsStorageEventEnabled],
|
|
||||||
)
|
|
||||||
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
|
||||||
"""Handle incoming storage hook events for recordings."""
|
|
||||||
|
|
||||||
parser = get_parser()
|
|
||||||
|
|
||||||
try:
|
|
||||||
recording_id = parser.get_recording_id(request.data)
|
|
||||||
|
|
||||||
except ParsingEventDataError as e:
|
|
||||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
|
||||||
|
|
||||||
except InvalidBucketError as e:
|
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
|
||||||
|
|
||||||
except InvalidFileTypeError as e:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"message": f"Ignore this file type, {e}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
recording = models.Recording.objects.get(id=recording_id)
|
|
||||||
except models.Recording.DoesNotExist as e:
|
|
||||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
|
||||||
|
|
||||||
if not recording.is_savable():
|
|
||||||
raise drf_exceptions.PermissionDenied(
|
|
||||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
|
||||||
" in an error state or has already been saved."
|
|
||||||
)
|
|
||||||
|
|
||||||
recording.status = models.RecordingStatusChoices.SAVED
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"message": "Event processed."},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -11,6 +10,8 @@ from mozilla_django_oidc.auth import (
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
@@ -67,76 +68,36 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||||
sub = user_info.get("sub")
|
sub = user_info.get("sub")
|
||||||
|
|
||||||
if not sub:
|
if sub is None:
|
||||||
raise SuspiciousOperation(
|
raise SuspiciousOperation(
|
||||||
_("User info contained no recognizable user identification")
|
_("User info contained no recognizable user identification")
|
||||||
)
|
)
|
||||||
|
|
||||||
email = user_info.get("email")
|
try:
|
||||||
user = self.get_existing_user(sub, email)
|
user = User.objects.get(sub=sub)
|
||||||
|
except User.DoesNotExist:
|
||||||
claims = {
|
if self.get_settings("OIDC_CREATE_USER", True):
|
||||||
"email": email,
|
user = self.create_user(user_info)
|
||||||
"full_name": self.compute_full_name(user_info),
|
else:
|
||||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
user = None
|
||||||
}
|
|
||||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
|
||||||
user = User.objects.create(
|
|
||||||
sub=sub,
|
|
||||||
password="!", # noqa: S106
|
|
||||||
**claims,
|
|
||||||
)
|
|
||||||
elif not user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise SuspiciousOperation(_("User account is disabled"))
|
|
||||||
|
|
||||||
self.update_user_if_needed(user, claims)
|
|
||||||
|
|
||||||
|
analytics.identify(user=user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def create_user(self, claims):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Return a newly created User instance."""
|
||||||
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
|
sub = claims.get("sub")
|
||||||
def compute_full_name(user_info):
|
|
||||||
"""Compute user's full name based on OIDC fields in settings."""
|
if sub is None:
|
||||||
full_name = " ".join(
|
raise SuspiciousOperation(
|
||||||
filter(
|
_("Claims contained no recognizable user identification")
|
||||||
None,
|
|
||||||
(
|
|
||||||
user_info.get(field)
|
|
||||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
user = User.objects.create(
|
||||||
|
sub=sub,
|
||||||
|
email=claims.get("email"),
|
||||||
|
password="!", # noqa: S106
|
||||||
)
|
)
|
||||||
return full_name or None
|
|
||||||
|
|
||||||
@staticmethod
|
return user
|
||||||
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,6 +22,8 @@ from mozilla_django_oidc.views import (
|
|||||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
|
|
||||||
|
|
||||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||||
@@ -98,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
|||||||
|
|
||||||
logout_url = self.redirect_url
|
logout_url = self.redirect_url
|
||||||
|
|
||||||
|
analytics.track(
|
||||||
|
user=request.user,
|
||||||
|
event="Signed Out",
|
||||||
|
)
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
logout_url = self.construct_oidc_logout_url(request)
|
logout_url = self.construct_oidc_logout_url(request)
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
full_name = factory.Faker("name")
|
|
||||||
short_name = factory.Faker("first_name")
|
|
||||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||||
password = make_password("password")
|
password = make_password("password")
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
skip_postgeneration_save = True
|
|
||||||
|
|
||||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||||
|
|
||||||
@@ -48,8 +45,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
else:
|
else:
|
||||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||||
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake resource user accesses for testing."""
|
"""Create fake resource user accesses for testing."""
|
||||||
@@ -70,52 +65,3 @@ class RoomFactory(ResourceFactory):
|
|||||||
|
|
||||||
name = factory.Faker("catch_phrase")
|
name = factory.Faker("catch_phrase")
|
||||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||||
|
|
||||||
|
|
||||||
class RecordingFactory(factory.django.DjangoModelFactory):
|
|
||||||
"""Create fake recording for testing."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.Recording
|
|
||||||
skip_postgeneration_save = True
|
|
||||||
|
|
||||||
room = factory.SubFactory(RoomFactory)
|
|
||||||
status = models.RecordingStatusChoices.INITIATED
|
|
||||||
mode = models.RecordingModeChoices.SCREEN_RECORDING
|
|
||||||
worker_id = None
|
|
||||||
|
|
||||||
@factory.post_generation
|
|
||||||
def users(self, create, extracted, **kwargs):
|
|
||||||
"""Add users to recording from a given list of users with or without roles."""
|
|
||||||
if create and extracted:
|
|
||||||
for item in extracted:
|
|
||||||
if isinstance(item, models.User):
|
|
||||||
UserRecordingAccessFactory(recording=self, user=item)
|
|
||||||
else:
|
|
||||||
UserRecordingAccessFactory(
|
|
||||||
recording=self, user=item[0], role=item[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
|
|
||||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
|
||||||
"""Create fake recording user accesses for testing."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.RecordingAccess
|
|
||||||
|
|
||||||
recording = factory.SubFactory(RecordingFactory)
|
|
||||||
user = factory.SubFactory(UserFactory)
|
|
||||||
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
|
|
||||||
"""Create fake recording team accesses for testing."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.RecordingAccess
|
|
||||||
|
|
||||||
recording = factory.SubFactory(RecordingFactory)
|
|
||||||
team = factory.Sequence(lambda n: f"team{n}")
|
|
||||||
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# Generated by Django 5.1.1 on 2024-11-06 14:31
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0004_alter_user_language'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Recording',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
|
||||||
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
|
|
||||||
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
|
|
||||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Recording',
|
|
||||||
'verbose_name_plural': 'Recordings',
|
|
||||||
'db_table': 'meet_recording',
|
|
||||||
'ordering': ('-created_at',),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='RecordingAccess',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
|
||||||
('team', models.CharField(blank=True, max_length=100)),
|
|
||||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
|
||||||
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
|
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Recording/user relation',
|
|
||||||
'verbose_name_plural': 'Recording/user relations',
|
|
||||||
'db_table': 'meet_recording_access',
|
|
||||||
'ordering': ('-created_at',),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='recording',
|
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='recordingaccess',
|
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='recordingaccess',
|
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='recordingaccess',
|
|
||||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db.models import Count
|
|
||||||
from core.models import RoleChoices
|
|
||||||
|
|
||||||
def merge_duplicate_user_accounts(apps, schema_editor):
|
|
||||||
"""Merge user accounts that share the same email address.
|
|
||||||
|
|
||||||
Historical Context:
|
|
||||||
Previously, ProConnect authentication could return users with the same email
|
|
||||||
but different sub, leading to duplicate user accounts. While the application
|
|
||||||
now prevents this scenario, this migration is needed to clean up existing
|
|
||||||
duplicate accounts to ensure users can continue to connect without being blocked
|
|
||||||
by unique email constraints.
|
|
||||||
|
|
||||||
Performance of this migration is poor, this implementation prioritizes readability
|
|
||||||
and maintainability. Consider refactoring this code to avoid individual db queries
|
|
||||||
on each iteration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
User = apps.get_model('core', 'User')
|
|
||||||
ResourceAccess = apps.get_model('core', 'ResourceAccess')
|
|
||||||
|
|
||||||
emails_with_duplicates = (
|
|
||||||
User.objects.values('email')
|
|
||||||
.annotate(count=Count('id'))
|
|
||||||
.filter(count__gt=1)
|
|
||||||
.values_list('email', flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
for email in emails_with_duplicates:
|
|
||||||
# Keep the oldest user
|
|
||||||
primary_user = User.objects.filter(email=email).order_by('created_at').first()
|
|
||||||
duplicate_user_accounts = User.objects.filter(email=email).exclude(id=primary_user.id)
|
|
||||||
|
|
||||||
# Get IDs of duplicate accounts to be merged
|
|
||||||
duplicate_account_ids = list(duplicate_user_accounts.values_list('id', flat=True))
|
|
||||||
resource_accesses_to_transfer = ResourceAccess.objects.filter(user_id__in=duplicate_account_ids)
|
|
||||||
|
|
||||||
# Transfer resource access permissions to primary user
|
|
||||||
# This process handles role hierarchy where:
|
|
||||||
# OWNER > ADMIN > MEMBER
|
|
||||||
for resource_access in resource_accesses_to_transfer:
|
|
||||||
|
|
||||||
# Determine if primary user already has access to this resource
|
|
||||||
existing_primary_access = ResourceAccess.objects.filter(
|
|
||||||
user_id=primary_user.id,
|
|
||||||
resource_id=resource_access.resource.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_primary_access:
|
|
||||||
# Skip if primary user is already OWNER as it's the highest privilege level
|
|
||||||
# No need to modify or downgrade owner access
|
|
||||||
if existing_primary_access.role == RoleChoices.OWNER:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if primary user already has the exact same role
|
|
||||||
# No need to update when roles match
|
|
||||||
elif existing_primary_access.role == resource_access.role:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if new role is MEMBER since user already has base access
|
|
||||||
# All existing access includes at least MEMBER privileges
|
|
||||||
elif resource_access.role == RoleChoices.MEMBER:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update the role only if it represents a higher privilege level
|
|
||||||
# Preserves existing access record while updating the role
|
|
||||||
existing_primary_access.role = resource_access.role
|
|
||||||
existing_primary_access.save()
|
|
||||||
else:
|
|
||||||
# Transfer access to primary user
|
|
||||||
resource_access.user_id = primary_user.id
|
|
||||||
resource_access.save()
|
|
||||||
|
|
||||||
# Delete duplicate accounts - CASCADE will automatically remove any untransferred
|
|
||||||
# ResourceAccess records and other related data for these users
|
|
||||||
duplicate_user_accounts.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0005_recording_recordingaccess_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(merge_duplicate_user_accounts, reverse_code=migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.1 on 2024-11-12 10:59
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0006_merge_duplicate_users'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='recording',
|
|
||||||
name='mode',
|
|
||||||
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the mode of recording being called.', max_length=20, verbose_name='Recording mode'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.1.1 on 2024-11-13 09:11
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0007_recording_mode'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='full_name',
|
|
||||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='short_name',
|
|
||||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
+9
-284
@@ -4,7 +4,6 @@ Declare and configure the models for the Meet core application
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import models as auth_models
|
from django.contrib.auth import models as auth_models
|
||||||
@@ -39,46 +38,6 @@ class RoleChoices(models.TextChoices):
|
|||||||
return role == cls.OWNER
|
return role == cls.OWNER
|
||||||
|
|
||||||
|
|
||||||
class RecordingStatusChoices(models.TextChoices):
|
|
||||||
"""Enumeration of possible states for a recording operation."""
|
|
||||||
|
|
||||||
INITIATED = "initiated", _("Initiated")
|
|
||||||
ACTIVE = "active", _("Active")
|
|
||||||
STOPPED = "stopped", _("Stopped")
|
|
||||||
SAVED = "saved", _("Saved")
|
|
||||||
ABORTED = "aborted", _("Aborted")
|
|
||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_final(cls, status):
|
|
||||||
"""Determine if the recording status represents a final state.
|
|
||||||
|
|
||||||
A final status indicates the recording flow has completed, either
|
|
||||||
successfully or unsuccessfully.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return status in {
|
|
||||||
cls.STOPPED,
|
|
||||||
cls.SAVED,
|
|
||||||
cls.ABORTED,
|
|
||||||
cls.FAILED_TO_START,
|
|
||||||
cls.FAILED_TO_STOP,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_unsuccessful(cls, status):
|
|
||||||
"""Determine if the recording status represents an unsuccessful state."""
|
|
||||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingModeChoices(models.TextChoices):
|
|
||||||
"""Recording mode choices."""
|
|
||||||
|
|
||||||
SCREEN_RECORDING = "screen_recording", _("SCREEN_RECORDING")
|
|
||||||
TRANSCRIPT = "transcript", _("TRANSCRIPT")
|
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
"""
|
"""
|
||||||
Serves as an abstract base model for other models, ensuring that records are validated
|
Serves as an abstract base model for other models, ensuring that records are validated
|
||||||
@@ -141,14 +100,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||||
|
|
||||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||||
# stores the email used by staff users to log in to the admin site
|
# stores the email used by staff users to login to the admin site
|
||||||
admin_email = models.EmailField(
|
admin_email = models.EmailField(
|
||||||
_("admin email address"), unique=True, blank=True, null=True
|
_("admin email address"), unique=True, blank=True, null=True
|
||||||
)
|
)
|
||||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
|
||||||
short_name = models.CharField(
|
|
||||||
_("short name"), max_length=100, null=True, blank=True
|
|
||||||
)
|
|
||||||
language = models.CharField(
|
language = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||||
@@ -207,39 +163,18 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
def email_anonymized(self):
|
||||||
"""
|
"""Anonymize the email address by replacing the local part with asterisks."""
|
||||||
Get all roles assigned to a user for a specific resource, including team-based roles.
|
if not self.email:
|
||||||
|
return ""
|
||||||
Args:
|
return f"***@{self.email.split('@')[1]}"
|
||||||
resource: The resource to check permissions for
|
|
||||||
user: The user to get roles for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of role strings assigned to the user
|
|
||||||
"""
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Use pre-annotated roles if available from viewset optimization
|
|
||||||
if hasattr(resource, "user_roles"):
|
|
||||||
return resource.user_roles or []
|
|
||||||
|
|
||||||
try:
|
|
||||||
return list(
|
|
||||||
resource.accesses.filter_user(user)
|
|
||||||
.values_list("role", flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
except (IndexError, models.ObjectDoesNotExist):
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class Resource(BaseModel):
|
class Resource(BaseModel):
|
||||||
"""Model to define access control"""
|
"""Model to define access control"""
|
||||||
|
|
||||||
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
|
||||||
users = models.ManyToManyField(
|
users = models.ManyToManyField(
|
||||||
User,
|
User,
|
||||||
through="ResourceAccess",
|
through="ResourceAccess",
|
||||||
@@ -390,213 +325,3 @@ class Room(Resource):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessManager(models.Manager):
|
|
||||||
"""Base manager for handling resource access control."""
|
|
||||||
|
|
||||||
def filter_user(self, user):
|
|
||||||
"""Filter accesses for a given user, including both direct and team-based access."""
|
|
||||||
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccess(BaseModel):
|
|
||||||
"""Base model for accesses to handle resources."""
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
team = models.CharField(max_length=100, blank=True)
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = BaseAccessManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
def _get_abilities(self, resource, user):
|
|
||||||
"""
|
|
||||||
Compute and return abilities for a given user taking into account
|
|
||||||
the current state of the object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
roles = get_resource_roles(resource, user)
|
|
||||||
|
|
||||||
is_owner = RoleChoices.OWNER in roles
|
|
||||||
has_privileges = is_owner or RoleChoices.ADMIN in roles
|
|
||||||
|
|
||||||
# Default values for unprivileged users
|
|
||||||
set_role_to = set()
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
# Special handling when modifying an owner's access
|
|
||||||
if self.role == RoleChoices.OWNER:
|
|
||||||
# Prevent orphaning the resource
|
|
||||||
can_delete = (
|
|
||||||
is_owner
|
|
||||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
|
||||||
)
|
|
||||||
if can_delete:
|
|
||||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
|
|
||||||
elif has_privileges:
|
|
||||||
can_delete = True
|
|
||||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
|
|
||||||
if is_owner:
|
|
||||||
set_role_to.add(RoleChoices.OWNER)
|
|
||||||
|
|
||||||
# Remove the current role as we don't want to propose it as an option
|
|
||||||
set_role_to.discard(self.role)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"destroy": can_delete,
|
|
||||||
"update": bool(set_role_to),
|
|
||||||
"partial_update": bool(set_role_to),
|
|
||||||
"retrieve": bool(roles),
|
|
||||||
"set_role_to": sorted(r.value for r in set_role_to),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Recording(BaseModel):
|
|
||||||
"""Model for recordings that take place in a room"""
|
|
||||||
|
|
||||||
room = models.ForeignKey(
|
|
||||||
Room,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="recordings",
|
|
||||||
verbose_name=_("Room"),
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=RecordingStatusChoices.choices,
|
|
||||||
default=RecordingStatusChoices.INITIATED,
|
|
||||||
)
|
|
||||||
worker_id = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("Worker ID"),
|
|
||||||
help_text=_(
|
|
||||||
"Enter an identifier for the worker recording."
|
|
||||||
"This ID is retained even when the worker stops, allowing for easy tracking."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mode = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=RecordingModeChoices.choices,
|
|
||||||
default=RecordingModeChoices.SCREEN_RECORDING,
|
|
||||||
verbose_name=_("Recording mode"),
|
|
||||||
help_text=_("Defines the mode of recording being called."),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "meet_recording"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
verbose_name = _("Recording")
|
|
||||||
verbose_name_plural = _("Recordings")
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["room"],
|
|
||||||
condition=models.Q(
|
|
||||||
status__in=[
|
|
||||||
RecordingStatusChoices.ACTIVE,
|
|
||||||
RecordingStatusChoices.INITIATED,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
name="unique_initiated_or_active_recording_per_room",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Recording {self.id} ({self.status})"
|
|
||||||
|
|
||||||
def get_abilities(self, user):
|
|
||||||
"""Compute and return abilities for a given user on the recording."""
|
|
||||||
|
|
||||||
roles = set(get_resource_roles(self, user))
|
|
||||||
|
|
||||||
is_owner_or_admin = bool(
|
|
||||||
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
|
||||||
)
|
|
||||||
|
|
||||||
is_final_status = RecordingStatusChoices.is_final(self.status)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"destroy": is_owner_or_admin and is_final_status,
|
|
||||||
"partial_update": False,
|
|
||||||
"retrieve": is_owner_or_admin,
|
|
||||||
"stop": is_owner_or_admin and not is_final_status,
|
|
||||||
"update": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def is_savable(self) -> bool:
|
|
||||||
"""Determine if the recording can be saved based on its current status."""
|
|
||||||
|
|
||||||
return self.status in {
|
|
||||||
RecordingStatusChoices.ACTIVE,
|
|
||||||
RecordingStatusChoices.STOPPED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingAccess(BaseAccess):
|
|
||||||
"""Relation model to give access to a recording for a user or a team with a role.
|
|
||||||
|
|
||||||
Recording Status Flow:
|
|
||||||
1. INITIATED: Initial state when recording is requested
|
|
||||||
2. ACTIVE: Recording is currently in progress
|
|
||||||
3. STOPPED: Recording has been stopped by user/system
|
|
||||||
4. SAVED: Recording has been successfully processed and stored
|
|
||||||
|
|
||||||
Error States:
|
|
||||||
- FAILED_TO_START: Worker failed to initialize recording
|
|
||||||
- FAILED_TO_STOP: Worker failed during stop operation
|
|
||||||
- ABORTED: Recording was terminated before completion
|
|
||||||
|
|
||||||
Warning: Worker failures may lead to database inconsistency between the actual
|
|
||||||
recording state and its status in the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
recording = models.ForeignKey(
|
|
||||||
Recording,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="accesses",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "meet_recording_access"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
verbose_name = _("Recording/user relation")
|
|
||||||
verbose_name_plural = _("Recording/user relations")
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["user", "recording"],
|
|
||||||
condition=models.Q(user__isnull=False), # Exclude null users
|
|
||||||
name="unique_recording_user",
|
|
||||||
violation_error_message=_("This user is already in this recording."),
|
|
||||||
),
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["team", "recording"],
|
|
||||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
|
||||||
name="unique_recording_team",
|
|
||||||
violation_error_message=_("This team is already in this recording."),
|
|
||||||
),
|
|
||||||
models.CheckConstraint(
|
|
||||||
condition=models.Q(user__isnull=False, team="")
|
|
||||||
| models.Q(user__isnull=True, team__gt=""),
|
|
||||||
name="check_recording_access_either_user_or_team",
|
|
||||||
violation_error_message=_("Either user or team must be set, not both."),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
|
|
||||||
|
|
||||||
def get_abilities(self, user):
|
|
||||||
"""
|
|
||||||
Compute and return abilities for a given user on the recording access.
|
|
||||||
"""
|
|
||||||
return self._get_abilities(self.recording, user)
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet event parser classes, authentication and exceptions."""
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"""Authentication class for storage event token validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from rest_framework.authentication import BaseAuthentication
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MachineUser:
|
|
||||||
"""Represent a non-interactive system user for automated storage operations."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.pk = None
|
|
||||||
self.username = "storage_event_user"
|
|
||||||
self.is_active = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated(self):
|
|
||||||
"""Indicate if this machine user is authenticated."""
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_anonymous(self) -> bool:
|
|
||||||
"""Indicate if this is an anonymous user."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_username(self) -> str:
|
|
||||||
"""Return the machine user identifier."""
|
|
||||||
return self.username
|
|
||||||
|
|
||||||
|
|
||||||
class StorageEventAuthentication(BaseAuthentication):
|
|
||||||
"""Authenticate requests using a Bearer token for storage event integration.
|
|
||||||
This class validates Bearer tokens for storage events that don't map to database users.
|
|
||||||
It's designed for S3-compatible storage integrations and similar use cases.
|
|
||||||
Events are submitted when a webhook is configured on some bucket's events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
|
||||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
|
||||||
return MachineUser(), None
|
|
||||||
|
|
||||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
|
||||||
return MachineUser(), None
|
|
||||||
|
|
||||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
|
||||||
if not required_token:
|
|
||||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
|
||||||
raise AuthenticationFailed(
|
|
||||||
_("Authentication is enabled but token is not configured.")
|
|
||||||
)
|
|
||||||
|
|
||||||
return MachineUser(), None
|
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
logger.warning(
|
|
||||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed(_("Authorization header is required"))
|
|
||||||
|
|
||||||
auth_parts = auth_header.split(" ")
|
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
|
||||||
logger.warning(
|
|
||||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
|
||||||
logger.warning(
|
|
||||||
"Authentication failed: Invalid token (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed(_("Invalid token"))
|
|
||||||
|
|
||||||
return MachineUser(), token
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return the WWW-Authenticate header value."""
|
|
||||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Storage parsers specific exceptions."""
|
|
||||||
|
|
||||||
|
|
||||||
class ParsingEventDataError(Exception):
|
|
||||||
"""Raised when the request data is malformed, incomplete, or missing."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidBucketError(Exception):
|
|
||||||
"""Raised when the bucket name in the request does not match the expected one."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidFileTypeError(Exception):
|
|
||||||
"""Raised when the file type in the request is not supported."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidFilepathError(Exception):
|
|
||||||
"""Raised when the filepath in the request is invalid."""
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Meet storage event parser classes."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Any, Dict, Optional, Protocol
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
from .exceptions import (
|
|
||||||
InvalidBucketError,
|
|
||||||
InvalidFilepathError,
|
|
||||||
InvalidFileTypeError,
|
|
||||||
ParsingEventDataError,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StorageEvent:
|
|
||||||
"""Represents a storage event with relevant metadata.
|
|
||||||
Attributes:
|
|
||||||
filepath: Identifier for the affected recording
|
|
||||||
filetype: Type of storage event
|
|
||||||
bucket_name: When the event occurred
|
|
||||||
metadata: Additional event data
|
|
||||||
"""
|
|
||||||
|
|
||||||
filepath: str
|
|
||||||
filetype: str
|
|
||||||
bucket_name: str
|
|
||||||
metadata: Optional[Dict[str, Any]]
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.filepath is None:
|
|
||||||
raise TypeError("filepath cannot be None")
|
|
||||||
if self.filetype is None:
|
|
||||||
raise TypeError("filetype cannot be None")
|
|
||||||
if self.bucket_name is None:
|
|
||||||
raise TypeError("bucket_name cannot be None")
|
|
||||||
|
|
||||||
|
|
||||||
class EventParser(Protocol):
|
|
||||||
"""Interface for parsing storage events."""
|
|
||||||
|
|
||||||
def __init__(self, bucket_name, allowed_filetypes=None):
|
|
||||||
"""Initialize parser with bucket name and optional allowed filetypes."""
|
|
||||||
|
|
||||||
def parse(self, data: Dict) -> StorageEvent:
|
|
||||||
"""Extract storage event data from raw dictionary input."""
|
|
||||||
|
|
||||||
def validate(self, data: StorageEvent) -> None:
|
|
||||||
"""Verify storage event data meets all requirements."""
|
|
||||||
|
|
||||||
def get_recording_id(self, data: Dict) -> str:
|
|
||||||
"""Extract recording ID from event dictionary."""
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_parser() -> EventParser:
|
|
||||||
"""Return cached instance of configured event parser.
|
|
||||||
Uses function memoization instead of a factory class since the only
|
|
||||||
varying parameter is the parser class from settings. A factory class
|
|
||||||
would add unnecessary complexity when a cached function provides the
|
|
||||||
same singleton behavior with simpler code.
|
|
||||||
"""
|
|
||||||
|
|
||||||
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
|
|
||||||
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
class MinioParser:
|
|
||||||
"""Handle parsing and validation of Minio storage events."""
|
|
||||||
|
|
||||||
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
|
||||||
"""Initialize parser with target bucket name and accepted filetypes."""
|
|
||||||
|
|
||||||
if not bucket_name:
|
|
||||||
raise ValueError("Bucket name cannot be None or empty")
|
|
||||||
|
|
||||||
self._bucket_name = bucket_name
|
|
||||||
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
|
|
||||||
|
|
||||||
# pylint: disable=line-too-long
|
|
||||||
self._filepath_regex = re.compile(
|
|
||||||
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse(data):
|
|
||||||
"""Convert raw Minio event dictionary to StorageEvent object."""
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
raise ParsingEventDataError("Received empty data.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
record = data["Records"][0]
|
|
||||||
s3 = record["s3"]
|
|
||||||
bucket_name = s3["bucket"]["name"]
|
|
||||||
file_object = s3["object"]
|
|
||||||
filepath = file_object["key"]
|
|
||||||
filetype = file_object["contentType"]
|
|
||||||
except (KeyError, IndexError) as e:
|
|
||||||
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
|
|
||||||
try:
|
|
||||||
return StorageEvent(
|
|
||||||
filepath=filepath,
|
|
||||||
filetype=filetype,
|
|
||||||
bucket_name=bucket_name,
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
except TypeError as e:
|
|
||||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
|
||||||
|
|
||||||
def validate(self, event_data: StorageEvent) -> str:
|
|
||||||
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
|
||||||
|
|
||||||
if event_data.bucket_name != self._bucket_name:
|
|
||||||
raise InvalidBucketError(
|
|
||||||
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not event_data.filetype in self._allowed_filetypes:
|
|
||||||
raise InvalidFileTypeError(
|
|
||||||
f"Invalid file type, expected {self._allowed_filetypes},"
|
|
||||||
f"got '{event_data.filetype}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
match = self._filepath_regex.match(event_data.filepath)
|
|
||||||
if not match:
|
|
||||||
raise InvalidFilepathError(
|
|
||||||
f"Invalid filepath structure: {event_data.filepath}"
|
|
||||||
)
|
|
||||||
|
|
||||||
recording_id = match.group("recording_id")
|
|
||||||
return recording_id
|
|
||||||
|
|
||||||
def get_recording_id(self, data):
|
|
||||||
"""Extract recording ID from Minio event through parsing and validation."""
|
|
||||||
|
|
||||||
event_data = self.parse(data)
|
|
||||||
recording_id = self.validate(event_data)
|
|
||||||
|
|
||||||
return recording_id
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet worker services classes and exceptions."""
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Recording and worker services specific exceptions."""
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerRequestError(Exception):
|
|
||||||
"""Raised when there is an issue with the worker request"""
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerConnectionError(Exception):
|
|
||||||
"""Raised when there is an issue connecting to the worker."""
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerResponseError(Exception):
|
|
||||||
"""Raised when the worker's response is not as expected."""
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingStartError(Exception):
|
|
||||||
"""Raised when there is an error starting the recording."""
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingStopError(Exception):
|
|
||||||
"""Raised when there is an error stopping the recording."""
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Factory, configurations and Protocol to create worker services"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class WorkerServiceConfig:
|
|
||||||
"""Declare Worker Service common configurations"""
|
|
||||||
|
|
||||||
output_folder: str
|
|
||||||
server_configurations: Dict[str, Any]
|
|
||||||
verify_ssl: Optional[bool]
|
|
||||||
bucket_args: Optional[dict]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@lru_cache
|
|
||||||
def from_settings(cls) -> "WorkerServiceConfig":
|
|
||||||
"""Load configuration from Django settings with caching for efficiency."""
|
|
||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
|
||||||
return cls(
|
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
|
||||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
|
||||||
verify_ssl=settings.RECORDING_VERIFY_SSL,
|
|
||||||
bucket_args={
|
|
||||||
"endpoint": settings.AWS_S3_ENDPOINT_URL,
|
|
||||||
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
|
|
||||||
"secret": settings.AWS_S3_SECRET_ACCESS_KEY,
|
|
||||||
"region": settings.AWS_S3_REGION_NAME,
|
|
||||||
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
|
||||||
"force_path_style": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerService(Protocol):
|
|
||||||
"""Define the interface for interacting with a worker service."""
|
|
||||||
|
|
||||||
hrid: ClassVar[str]
|
|
||||||
|
|
||||||
def __init__(self, config: WorkerServiceConfig):
|
|
||||||
"""Initialize the service with the given configuration."""
|
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
|
||||||
"""Start a recording for a specified room."""
|
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
|
||||||
"""Stop recording for a specified worker."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_worker_service(mode: str) -> WorkerService:
|
|
||||||
"""Instantiate a worker service by its mode."""
|
|
||||||
|
|
||||||
worker_registry: Dict[str, str] = settings.RECORDING_WORKER_CLASSES
|
|
||||||
|
|
||||||
try:
|
|
||||||
worker_class_path = worker_registry[mode]
|
|
||||||
except KeyError as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Recording mode '{mode}' not found in RECORDING_WORKER_CLASSES. "
|
|
||||||
f"Available modes: {list(worker_registry.keys())}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
worker_class: Type[WorkerService] = import_string(worker_class_path)
|
|
||||||
|
|
||||||
config = WorkerServiceConfig.from_settings()
|
|
||||||
return worker_class(config=config)
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""Mediator between the worker service and recording instances in the Django ORM."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from core.models import Recording, RecordingStatusChoices
|
|
||||||
|
|
||||||
from .exceptions import (
|
|
||||||
RecordingStartError,
|
|
||||||
RecordingStopError,
|
|
||||||
WorkerConnectionError,
|
|
||||||
WorkerRequestError,
|
|
||||||
WorkerResponseError,
|
|
||||||
)
|
|
||||||
from .factories import WorkerService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerServiceMediator:
|
|
||||||
"""Mediate interactions between a worker service and a recording instance.
|
|
||||||
|
|
||||||
A mediator class that decouples the worker from Django ORM, handles recording updates
|
|
||||||
based on worker status, and transforms worker errors into user-friendly exceptions.
|
|
||||||
Implements Mediator pattern.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, worker_service: WorkerService):
|
|
||||||
"""Initialize the WorkerServiceMediator with the provided worker service."""
|
|
||||||
|
|
||||||
self._worker_service = worker_service
|
|
||||||
|
|
||||||
def start(self, recording: Recording):
|
|
||||||
"""Start the recording process using the worker service.
|
|
||||||
|
|
||||||
If the operation is successful, the recording's status will
|
|
||||||
transition from INITIATED to ACTIVE, else to FAILED_TO_START to keep track of errors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recording (Recording): The recording instance to start.
|
|
||||||
Raises:
|
|
||||||
RecordingStartError: If there is an error starting the recording.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if recording.status != RecordingStatusChoices.INITIATED:
|
|
||||||
logger.error("Cannot start recording in %s status.", recording.status)
|
|
||||||
raise RecordingStartError()
|
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
|
||||||
try:
|
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
|
||||||
)
|
|
||||||
recording.status = RecordingStatusChoices.FAILED_TO_START
|
|
||||||
raise RecordingStartError() from e
|
|
||||||
else:
|
|
||||||
recording.worker_id = worker_id
|
|
||||||
recording.status = RecordingStatusChoices.ACTIVE
|
|
||||||
finally:
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Worker started for room %s (worker ID: %s)",
|
|
||||||
recording.room,
|
|
||||||
recording.worker_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self, recording: Recording):
|
|
||||||
"""Stop the recording process using the worker service.
|
|
||||||
|
|
||||||
If the operation is successful, the recording's status will transition
|
|
||||||
from ACTIVE to STOPPED, else to FAILED_TO_STOP to keep track of errors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recording (Recording): The recording instance to stop.
|
|
||||||
Raises:
|
|
||||||
RecordingStopError: If there is an error stopping the recording.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if recording.status != RecordingStatusChoices.ACTIVE:
|
|
||||||
logger.error("Cannot stop recording in %s status.", recording.status)
|
|
||||||
raise RecordingStopError()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self._worker_service.stop(worker_id=recording.worker_id)
|
|
||||||
except (WorkerConnectionError, WorkerResponseError) as e:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to stop recording for room %s: %s", recording.room.slug, e
|
|
||||||
)
|
|
||||||
recording.status = RecordingStatusChoices.FAILED_TO_STOP
|
|
||||||
raise RecordingStopError() from e
|
|
||||||
else:
|
|
||||||
recording.status = RecordingStatusChoices[response]
|
|
||||||
finally:
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
logger.info("Worker stopped for room %s", recording.room)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Worker services in charge of recording a room."""
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from livekit import api as livekit_api
|
|
||||||
from livekit.api.egress_service import EgressService
|
|
||||||
|
|
||||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
|
||||||
from .factories import WorkerServiceConfig
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEgressService:
|
|
||||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
|
||||||
|
|
||||||
def __init__(self, config: WorkerServiceConfig):
|
|
||||||
self._config = config
|
|
||||||
self._s3 = livekit_api.S3Upload(**config.bucket_args)
|
|
||||||
|
|
||||||
def _get_filepath(self, filename: str, extension: str) -> str:
|
|
||||||
"""Construct the file path for a given filename and extension.
|
|
||||||
Unsecure method, doesn't handle paths robustly and securely.
|
|
||||||
"""
|
|
||||||
return f"{self._config.output_folder}/{filename}.{extension}"
|
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def _handle_request(self, request, method_name: str):
|
|
||||||
"""Handle making a request to the LiveKit API and returns the response."""
|
|
||||||
|
|
||||||
# Use HTTP connector for local development with Tilt,
|
|
||||||
# where cluster communications are unsecure
|
|
||||||
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession(connector=connector) as session:
|
|
||||||
client = EgressService(session, **self._config.server_configurations)
|
|
||||||
method = getattr(client, method_name)
|
|
||||||
try:
|
|
||||||
response = await method(request)
|
|
||||||
except livekit_api.TwirpError as e:
|
|
||||||
raise WorkerConnectionError(
|
|
||||||
f"LiveKit client connection error, {e.message}."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
|
||||||
"""Stop an ongoing egress worker.
|
|
||||||
The StopEgressRequest is shared among all types of egress,
|
|
||||||
so a single implementation in the base class should be sufficient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
request = livekit_api.StopEgressRequest(
|
|
||||||
egress_id=worker_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self._handle_request(request, "stop_egress")
|
|
||||||
|
|
||||||
if not response.status:
|
|
||||||
raise WorkerResponseError(
|
|
||||||
"LiveKit response is missing the recording status."
|
|
||||||
)
|
|
||||||
|
|
||||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
|
||||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
|
||||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
|
||||||
return "ABORTED"
|
|
||||||
|
|
||||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
|
||||||
return "STOPPED"
|
|
||||||
|
|
||||||
return "FAILED_TO_STOP"
|
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Subclass must implement this method.")
|
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
|
||||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
|
||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
|
||||||
"""Start the video composite egress process for a recording."""
|
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
|
||||||
file_type = livekit_api.EncodedFileType.MP4
|
|
||||||
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
|
||||||
|
|
||||||
file_output = livekit_api.EncodedFileOutput(
|
|
||||||
file_type=file_type,
|
|
||||||
filepath=filepath,
|
|
||||||
s3=self._s3,
|
|
||||||
)
|
|
||||||
|
|
||||||
request = livekit_api.RoomCompositeEgressRequest(
|
|
||||||
room_name=room_name,
|
|
||||||
file_outputs=[file_output],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self._handle_request(request, "start_room_composite_egress")
|
|
||||||
|
|
||||||
if not response.egress_id:
|
|
||||||
raise WorkerResponseError("Egress ID not found in the response.")
|
|
||||||
|
|
||||||
return response.egress_id
|
|
||||||
|
|
||||||
|
|
||||||
class AudioCompositeEgressService(BaseEgressService):
|
|
||||||
"""Record multiple participant audio tracks into a single output '.ogg' file."""
|
|
||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
|
||||||
"""Start the audio composite egress process for a recording."""
|
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
|
||||||
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
|
||||||
|
|
||||||
file_output = livekit_api.EncodedFileOutput(
|
|
||||||
file_type=file_type,
|
|
||||||
filepath=filepath,
|
|
||||||
s3=self._s3,
|
|
||||||
)
|
|
||||||
|
|
||||||
request = livekit_api.RoomCompositeEgressRequest(
|
|
||||||
room_name=room_name, file_outputs=[file_output], audio_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self._handle_request(request, "start_room_composite_egress")
|
|
||||||
|
|
||||||
if not response.egress_id:
|
|
||||||
raise WorkerResponseError("Egress ID not found in the response.")
|
|
||||||
|
|
||||||
return response.egress_id
|
|
||||||
@@ -11,35 +11,32 @@ from core.factories import UserFactory
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_existing_user(monkeypatch):
|
def test_authentication_getter_existing_user_no_email(
|
||||||
|
django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
If an existing user matches, the user should be returned.
|
If an existing user matches the user's info sub, the user should be returned.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
db_user = UserFactory(email="foo@mail.com")
|
db_user = UserFactory()
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {"sub": db_user.sub, "email": "some@mail.com"}
|
return {"sub": db_user.sub}
|
||||||
|
|
||||||
def get_existing_user(*args):
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
monkeypatch.setattr(
|
|
||||||
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
|
|
||||||
)
|
|
||||||
|
|
||||||
user = klass.get_or_create_user(
|
with django_assert_num_queries(1):
|
||||||
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
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches, a user should be created.
|
If no user matches the user's info sub, a user should be created.
|
||||||
User's info doesn't contain an email, created user's email should be empty.
|
User's info doesn't contain an email, created user's email should be empty.
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
@@ -61,15 +58,16 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
|
|
||||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches, a user should be created.
|
If no user matches the user's info sub, a user should be created.
|
||||||
User's info contains an email, created user's email should be filled.
|
User's email and name should be set on the identity.
|
||||||
|
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
email = "meet@example.com"
|
email = "meet@example.com"
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {"sub": "123", "email": email}
|
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
@@ -79,34 +77,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
|||||||
|
|
||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name is None
|
|
||||||
assert user.short_name is None
|
|
||||||
assert user.password == "!"
|
|
||||||
assert models.User.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
|
|
||||||
def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
|
||||||
"""
|
|
||||||
If no user matches, a user should be created.
|
|
||||||
User's info contains name-related field, created user's full and short names should be filled,
|
|
||||||
whether the email is filled
|
|
||||||
"""
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
user = klass.get_or_create_user(
|
|
||||||
access_token="test-token", id_token=None, payload=None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert user.sub == "123"
|
|
||||||
assert user.email == email
|
|
||||||
assert user.full_name == "John Doe"
|
|
||||||
assert user.short_name == "John"
|
|
||||||
assert user.password == "!"
|
assert user.password == "!"
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
@@ -132,283 +102,3 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
|||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
assert models.User.objects.exists() is False
|
||||||
|
|
||||||
|
|
||||||
def test_models_oidc_user_getter_empty_sub(django_assert_num_queries, monkeypatch):
|
|
||||||
"""The user's info contains a sub, but it's an empty string."""
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {"test": "123", "sub": ""}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
with (
|
|
||||||
django_assert_num_queries(0),
|
|
||||||
pytest.raises(
|
|
||||||
SuspiciousOperation,
|
|
||||||
match="User info contained no recognizable user identification",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_get_inactive_user(monkeypatch):
|
|
||||||
"""Test an exception is raised when attempting to authenticate inactive user."""
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(is_active=False)
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {"sub": db_user.sub}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
with (
|
|
||||||
pytest.raises(
|
|
||||||
SuspiciousOperation,
|
|
||||||
match="User account is disabled",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_by_sub(django_assert_num_queries):
|
|
||||||
"""Should return user when found by sub, and email is matching."""
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
with django_assert_num_queries(1):
|
|
||||||
user = klass.get_existing_user(db_user.sub, db_user.email)
|
|
||||||
|
|
||||||
assert user == db_user
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
|
||||||
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
with django_assert_num_queries(1):
|
|
||||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
|
||||||
|
|
||||||
assert user is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
|
||||||
"""Should not return a user when not found by sub, and email is empty."""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
empty_email = ""
|
|
||||||
|
|
||||||
with django_assert_num_queries(1):
|
|
||||||
user = klass.get_existing_user("wrong-sub", empty_email)
|
|
||||||
|
|
||||||
assert user is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_by_email(django_assert_num_queries, settings):
|
|
||||||
"""Should return user when found by email, and sub is not matching."""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
|
||||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
|
||||||
|
|
||||||
assert user == db_user
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
|
||||||
"""Should match email case-insensitively when falling back to email."""
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
|
||||||
user = klass.get_existing_user("wrong-sub", "FOO@MAIL.COM")
|
|
||||||
|
|
||||||
assert user == db_user
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
|
||||||
"""Should handle multiple users with same email appropriately."""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
email = "foo@mail.com"
|
|
||||||
UserFactory(email=email)
|
|
||||||
UserFactory(email=email) # Second user with same email
|
|
||||||
|
|
||||||
with (
|
|
||||||
django_assert_num_queries(2),
|
|
||||||
pytest.raises(
|
|
||||||
SuspiciousOperation,
|
|
||||||
match="Multiple user accounts share a common email.",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
klass.get_existing_user("wrong-sub", email)
|
|
||||||
|
|
||||||
|
|
||||||
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
|
||||||
"""Should not match emails with whitespace."""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
settings.OIDC_CREATE_USER = False
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
UserFactory(email="foo@mail.com")
|
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
|
||||||
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
|
||||||
|
|
||||||
assert user is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"email",
|
|
||||||
[
|
|
||||||
"john.doe@example.com", # Fullwidth character in domain
|
|
||||||
"john.doe@еxample.com", # Cyrillic 'е' in domain
|
|
||||||
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
|
|
||||||
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
|
|
||||||
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
|
|
||||||
"""Test email matching security against visually similar but non-ASCII domains.
|
|
||||||
|
|
||||||
Validates that emails with Unicode characters that visually resemble ASCII
|
|
||||||
(homoglyphs) are treated as distinct from their ASCII counterparts for security,
|
|
||||||
per RFC compliance requirements for hostnames.
|
|
||||||
"""
|
|
||||||
|
|
||||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
db_user = UserFactory(email="john.doe@example.com")
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {"sub": "123", "email": email}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
user = klass.get_or_create_user(
|
|
||||||
access_token="test-token", id_token=None, payload=None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert user != db_user
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"given_name, usual_name, email",
|
|
||||||
[
|
|
||||||
("Jack", "Doe", "john.doe@example.com"),
|
|
||||||
("John", "Duy", "john.doe@example.com"),
|
|
||||||
("John", "Doe", "jack.duy@example.com"),
|
|
||||||
("Jack", "Duy", "jack.duy@example.com"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_authentication_getter_existing_user_change_fields(
|
|
||||||
given_name, usual_name, email, django_assert_num_queries, monkeypatch
|
|
||||||
):
|
|
||||||
"""It should update the email or name fields on the user when they change."""
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
user = UserFactory(
|
|
||||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {
|
|
||||||
"sub": user.sub,
|
|
||||||
"email": email,
|
|
||||||
"given_name": given_name,
|
|
||||||
"usual_name": usual_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
|
||||||
with django_assert_num_queries(2):
|
|
||||||
authenticated_user = klass.get_or_create_user(
|
|
||||||
access_token="test-token", id_token=None, payload=None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert user == authenticated_user
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.email == email
|
|
||||||
assert user.full_name == f"{given_name:s} {usual_name:s}"
|
|
||||||
assert user.short_name == given_name
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user_info, expected_name",
|
|
||||||
[
|
|
||||||
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
|
|
||||||
(
|
|
||||||
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
|
|
||||||
"John M Doe",
|
|
||||||
),
|
|
||||||
({"family_name": "Doe"}, "Doe"),
|
|
||||||
({"given_name": "", "family_name": ""}, None),
|
|
||||||
({}, None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_compute_full_name(user_info, expected_name, settings):
|
|
||||||
"""Test full name computation from OIDC user info fields."""
|
|
||||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
|
|
||||||
"given_name",
|
|
||||||
"middle_name",
|
|
||||||
"family_name",
|
|
||||||
]
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
assert klass.compute_full_name(user_info) == expected_name
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_full_name_no_fields(settings):
|
|
||||||
"""Test full name computation with empty field configuration."""
|
|
||||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
assert klass.compute_full_name({"given_name": "John"}) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"claims",
|
|
||||||
[
|
|
||||||
{"email": "john.doe@example.com"}, # Same data - no change needed
|
|
||||||
{"email": ""}, # Empty strings should not override
|
|
||||||
{"non_related_field": "foo"}, # Unrelated fields should be ignored
|
|
||||||
{}, # Empty claims should not affect user
|
|
||||||
{"email": None}, # None values should be ignored
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
|
||||||
"""Test that user attributes remain unchanged when claims don't require updates."""
|
|
||||||
|
|
||||||
user = UserFactory(
|
|
||||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
|
|
||||||
with django_assert_num_queries(0):
|
|
||||||
klass.update_user_if_needed(user, claims)
|
|
||||||
|
|
||||||
user.refresh_from_db()
|
|
||||||
|
|
||||||
assert user.email == "john.doe@example.com"
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
"""
|
|
||||||
Test event authentication.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=E1128
|
|
||||||
|
|
||||||
from django.test import RequestFactory
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
|
|
||||||
from core.recording.event.authentication import (
|
|
||||||
MachineUser,
|
|
||||||
StorageEventAuthentication,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_successful_authentication(settings):
|
|
||||||
"""Test successful authentication with valid token."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Bearer valid-test-token"}
|
|
||||||
|
|
||||||
user, token = StorageEventAuthentication().authenticate(request)
|
|
||||||
assert token == "valid-test-token"
|
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_authentication_with_header(settings):
|
|
||||||
"""Authentication should pass when no auth is configured, and header is present."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
|
||||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
|
||||||
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Bearer some-token"}
|
|
||||||
|
|
||||||
user, token = StorageEventAuthentication().authenticate(request)
|
|
||||||
assert token is None
|
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_authentication_without_header(settings):
|
|
||||||
"""Authentication should pass when no auth is configured, and no header is present."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
|
||||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
|
||||||
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
|
|
||||||
user, token = StorageEventAuthentication().authenticate(request)
|
|
||||||
assert token is None
|
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_when_disabled(settings):
|
|
||||||
"""Authentication should pass when disabled, regardless of token configuration."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
|
|
||||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
|
||||||
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
|
|
||||||
user, token = StorageEventAuthentication().authenticate(request)
|
|
||||||
assert token is None
|
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_fails_when_token_not_configured(settings):
|
|
||||||
"""Authentication should fail when authentication is enabled but no token is configured."""
|
|
||||||
|
|
||||||
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
|
||||||
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
AuthenticationFailed,
|
|
||||||
match="Authentication is enabled but token is not configured",
|
|
||||||
):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_auth_header(settings):
|
|
||||||
"""Test failure when Authorization header is missing."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {}
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_auth_header_format(settings):
|
|
||||||
"""Test failure when Authorization header has invalid format."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "InvalidFormat"}
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token_type(settings):
|
|
||||||
"""Test failure when token type is not Bearer."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Basic some-token"}
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token(settings):
|
|
||||||
"""Test failure when token is invalid."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Bearer wrong-token"}
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_auth_header(settings):
|
|
||||||
"""Test failure when Authorization header is malformed."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Bearer"} # Missing token part
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_header():
|
|
||||||
"""Test the WWW-Authenticate header value."""
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
header = StorageEventAuthentication().authenticate_header(request)
|
|
||||||
assert header == "Bearer realm='Storage event API'"
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
|
||||||
"""Test failure when Authorization header contains multiple spaces."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
|
||||||
StorageEventAuthentication().authenticate(request)
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
"""
|
|
||||||
Test event parsers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0212,W0621,W0613
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.recording.event.exceptions import (
|
|
||||||
InvalidBucketError,
|
|
||||||
InvalidFilepathError,
|
|
||||||
InvalidFileTypeError,
|
|
||||||
ParsingEventDataError,
|
|
||||||
)
|
|
||||||
from core.recording.event.parsers import (
|
|
||||||
MinioParser,
|
|
||||||
StorageEvent,
|
|
||||||
get_parser,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def valid_minio_event():
|
|
||||||
"""Mock a valid Minio event."""
|
|
||||||
return {
|
|
||||||
"Records": [
|
|
||||||
{
|
|
||||||
"s3": {
|
|
||||||
"bucket": {"name": "test-bucket"},
|
|
||||||
"object": {
|
|
||||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
"contentType": "audio/ogg",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def minio_parser():
|
|
||||||
"""Mock a Minio parser."""
|
|
||||||
return MinioParser(bucket_name="test-bucket")
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_valid_event(minio_parser, valid_minio_event):
|
|
||||||
"""Test parsing a valid Minio event."""
|
|
||||||
event = minio_parser.parse(valid_minio_event)
|
|
||||||
assert isinstance(event, StorageEvent)
|
|
||||||
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
|
||||||
assert event.filetype == "audio/ogg"
|
|
||||||
assert event.bucket_name == "test-bucket"
|
|
||||||
assert event.metadata is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_empty_data(minio_parser):
|
|
||||||
"""Test parsing empty event data raises error."""
|
|
||||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
|
||||||
minio_parser.parse({})
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_missing_keys(minio_parser):
|
|
||||||
"""Test parsing event with missing key."""
|
|
||||||
|
|
||||||
invalid_minio_event = {
|
|
||||||
"Records": [
|
|
||||||
{
|
|
||||||
"s3": {
|
|
||||||
"bucket": {"name": None},
|
|
||||||
# Missing 'object' key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
|
|
||||||
minio_parser.parse(invalid_minio_event)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_none_key(minio_parser):
|
|
||||||
"""Test parsing event with None field."""
|
|
||||||
|
|
||||||
invalid_minio_event = {
|
|
||||||
"Records": [
|
|
||||||
{
|
|
||||||
"s3": {
|
|
||||||
"bucket": {"name": "test-bucket"},
|
|
||||||
"object": {
|
|
||||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
"contentType": None, # 'contentType' should not be None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
|
|
||||||
minio_parser.parse(invalid_minio_event)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_invalid_bucket(minio_parser):
|
|
||||||
"""Test validation with wrong bucket name."""
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
filetype="audio/ogg",
|
|
||||||
bucket_name="wrong-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(InvalidBucketError):
|
|
||||||
minio_parser.validate(event)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_invalid_filetype(minio_parser):
|
|
||||||
"""Test validation with unsupported file type."""
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
|
||||||
filetype="text/plain", # Not included in the default allowed filetypes
|
|
||||||
bucket_name="test-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(InvalidFileTypeError):
|
|
||||||
minio_parser.validate(event)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"invalid_filepath",
|
|
||||||
[
|
|
||||||
"invalid_filepath",
|
|
||||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
|
||||||
"""Test validation with malformed filepath."""
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath=invalid_filepath,
|
|
||||||
filetype="audio/ogg",
|
|
||||||
bucket_name="test-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(InvalidFilepathError):
|
|
||||||
minio_parser.validate(event)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_valid_event(minio_parser):
|
|
||||||
"""Test validation with valid event data."""
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
filetype="audio/ogg",
|
|
||||||
bucket_name="test-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
recording_id = minio_parser.validate(event)
|
|
||||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_recording_id_success(minio_parser, valid_minio_event):
|
|
||||||
"""Test successful extraction of recording ID."""
|
|
||||||
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
|
||||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_filepath_with_folder(minio_parser):
|
|
||||||
"""Test validation of filepath with folder structure."""
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
filetype="audio/ogg",
|
|
||||||
bucket_name="test-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
recording_id = minio_parser.validate(event)
|
|
||||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_with_video_type(minio_parser):
|
|
||||||
"""Test parsing event with video file type."""
|
|
||||||
video_event = {
|
|
||||||
"Records": [
|
|
||||||
{
|
|
||||||
"s3": {
|
|
||||||
"bucket": {"name": "test-bucket"},
|
|
||||||
"object": {
|
|
||||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
|
||||||
"contentType": "video/mp4",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
event = minio_parser.parse(video_event)
|
|
||||||
assert event.filetype == "video/mp4"
|
|
||||||
assert event.filepath.endswith(".mp4")
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_allowed_filetypes():
|
|
||||||
"""Test MinioParser with empty allowed_filetypes."""
|
|
||||||
empty_types = set()
|
|
||||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
|
||||||
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_allowed_filetypes():
|
|
||||||
"""Test MinioParser with empty allowed_filetypes."""
|
|
||||||
custom_types = {"audio/mp3", "video/mov"}
|
|
||||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
|
||||||
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_custom_filetypes():
|
|
||||||
"""Test validation of filepath with folder structure."""
|
|
||||||
|
|
||||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
|
||||||
|
|
||||||
event = StorageEvent(
|
|
||||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
|
||||||
filetype="audio/mp3",
|
|
||||||
bucket_name="test-bucket",
|
|
||||||
metadata=None,
|
|
||||||
)
|
|
||||||
parser.validate(event)
|
|
||||||
|
|
||||||
|
|
||||||
def test_constructor_none_bucket():
|
|
||||||
"""Test MinioParser constructor with None bucket name."""
|
|
||||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
|
||||||
MinioParser(bucket_name=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_constructor_empty_bucket():
|
|
||||||
"""Test MinioParser constructor with empty bucket name."""
|
|
||||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
|
||||||
MinioParser(bucket_name="")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def clear_lru_cache():
|
|
||||||
"""Fixture to clear the LRU cache between tests."""
|
|
||||||
get_parser.cache_clear()
|
|
||||||
yield
|
|
||||||
get_parser.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_correct_instance(clear_lru_cache):
|
|
||||||
"""Test if get_parser returns the correct parser instance."""
|
|
||||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
|
||||||
parser = get_parser()
|
|
||||||
assert isinstance(parser, MinioParser)
|
|
||||||
assert parser._bucket_name == "test-bucket"
|
|
||||||
|
|
||||||
|
|
||||||
def test_caching_behavior(clear_lru_cache):
|
|
||||||
"""Test if the function properly caches the parser instance."""
|
|
||||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
|
||||||
parser1 = get_parser()
|
|
||||||
parser2 = get_parser()
|
|
||||||
assert parser1 is parser2 # Check object identity
|
|
||||||
|
|
||||||
|
|
||||||
def test_different_settings_new_instance():
|
|
||||||
"""Test if changing settings creates a new instance."""
|
|
||||||
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
|
|
||||||
parser = get_parser()
|
|
||||||
assert parser._bucket_name == "different-bucket"
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_error_handling(clear_lru_cache):
|
|
||||||
"""Test handling of import errors for invalid parser class."""
|
|
||||||
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
|
||||||
with pytest.raises(ImportError):
|
|
||||||
get_parser()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.recording.event.parsers.import_string")
|
|
||||||
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
|
||||||
"""Test that parser class is instantiated only once due to caching."""
|
|
||||||
mock_parser_cls = type(
|
|
||||||
"MockParser",
|
|
||||||
(),
|
|
||||||
{
|
|
||||||
"__init__": lambda self, bucket_name: setattr(
|
|
||||||
self, "_bucket_name", bucket_name
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
mock_import_string.return_value = mock_parser_cls
|
|
||||||
|
|
||||||
# First call
|
|
||||||
parser1 = get_parser()
|
|
||||||
# Second call
|
|
||||||
parser2 = get_parser()
|
|
||||||
|
|
||||||
# Verify import_string was called only once
|
|
||||||
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
|
|
||||||
assert parser1 is parser2
|
|
||||||
|
|
||||||
|
|
||||||
def test_cache_clear_behavior(clear_lru_cache, settings):
|
|
||||||
"""Test that cache clearing creates new instance."""
|
|
||||||
|
|
||||||
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
|
|
||||||
|
|
||||||
parser1 = get_parser()
|
|
||||||
get_parser.cache_clear()
|
|
||||||
parser2 = get_parser()
|
|
||||||
|
|
||||||
assert parser1 is not parser2 # Should be different instances after cache clear
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""
|
|
||||||
Test recordings API endpoints in the Meet core app: delete.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
|
||||||
from ...models import Recording
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_delete_anonymous():
|
|
||||||
"""Anonymous users should not be allowed to destroy a recording."""
|
|
||||||
recording = RecordingFactory()
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.delete(
|
|
||||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_delete_authenticated():
|
|
||||||
"""
|
|
||||||
Authenticated users should not be allowed to delete a recording
|
|
||||||
from which they are not related.
|
|
||||||
"""
|
|
||||||
recording = RecordingFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.delete(
|
|
||||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_delete_members():
|
|
||||||
"""
|
|
||||||
Authenticated users should not be allowed to delete a recording
|
|
||||||
from which they are only a member.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
access = UserRecordingAccessFactory(role="member", user=user)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.delete(
|
|
||||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role",
|
|
||||||
["owner", "administrator"],
|
|
||||||
)
|
|
||||||
def test_api_recordings_delete_active(role):
|
|
||||||
"""
|
|
||||||
Authenticated users cannot delete active recordings, even with deletion privileges.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
recording = RecordingFactory(status="active")
|
|
||||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.delete(
|
|
||||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role",
|
|
||||||
["owner", "administrator"],
|
|
||||||
)
|
|
||||||
def test_api_recordings_delete_final(role):
|
|
||||||
"""
|
|
||||||
Authenticated users should not be allowed to delete an active recording
|
|
||||||
from which they are an admin or owner.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
recording = RecordingFactory(status="saved")
|
|
||||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.delete(
|
|
||||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""
|
|
||||||
Test recordings API endpoints in the Meet core app: list.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import operator
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.pagination import PageNumberPagination
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_list_anonymous():
|
|
||||||
"""Anonymous users should not be able to list recordings."""
|
|
||||||
factories.RecordingFactory()
|
|
||||||
response = APIClient().get("/api/v1.0/recordings/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role",
|
|
||||||
["administrator", "member", "owner"],
|
|
||||||
)
|
|
||||||
def test_api_recordings_list_authenticated_direct(role):
|
|
||||||
"""
|
|
||||||
Authenticated users listing recordings, should only see the recordings
|
|
||||||
to which they are related.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
other_user = factories.UserFactory()
|
|
||||||
|
|
||||||
access = factories.UserRecordingAccessFactory(role=role, user=user)
|
|
||||||
factories.UserRecordingAccessFactory(user=other_user)
|
|
||||||
|
|
||||||
recording = access.recording
|
|
||||||
room = recording.room
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
results = response.json()["results"]
|
|
||||||
assert len(results) == 1
|
|
||||||
expected_ids = {
|
|
||||||
str(recording.id),
|
|
||||||
}
|
|
||||||
result_ids = {result["id"] for result in results}
|
|
||||||
assert expected_ids == result_ids
|
|
||||||
assert results[0] == {
|
|
||||||
"id": str(recording.id),
|
|
||||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
|
||||||
"room": {
|
|
||||||
"id": str(room.id),
|
|
||||||
"is_public": room.is_public,
|
|
||||||
"name": room.name,
|
|
||||||
"slug": room.slug,
|
|
||||||
},
|
|
||||||
"status": "initiated",
|
|
||||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
|
|
||||||
"""
|
|
||||||
Authenticated users should be able to list recordings they are a
|
|
||||||
owner/administrator/member of via a team.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
|
|
||||||
|
|
||||||
recordings_team1 = [
|
|
||||||
access.recording
|
|
||||||
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
|
|
||||||
]
|
|
||||||
recordings_team2 = [
|
|
||||||
access.recording
|
|
||||||
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
|
|
||||||
]
|
|
||||||
|
|
||||||
expected_ids = {
|
|
||||||
str(recording.id) for recording in recordings_team1 + recordings_team2
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
results = response.json()["results"]
|
|
||||||
assert len(results) == 5
|
|
||||||
results_id = {result["id"] for result in results}
|
|
||||||
assert expected_ids == results_id
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
|
||||||
def test_api_recordings_list_pagination(_mock_page_size):
|
|
||||||
"""Pagination should work as expected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
recording_ids = [
|
|
||||||
str(access.recording_id)
|
|
||||||
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
|
|
||||||
]
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["count"] == 3
|
|
||||||
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
|
|
||||||
assert content["previous"] is None
|
|
||||||
|
|
||||||
assert len(content["results"]) == 2
|
|
||||||
for item in content["results"]:
|
|
||||||
recording_ids.remove(item["id"])
|
|
||||||
|
|
||||||
# Get page 2
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/?page=2",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
|
|
||||||
assert content["count"] == 3
|
|
||||||
assert content["next"] is None
|
|
||||||
assert content["previous"], "http://testserver/api/v1.0/recordings/"
|
|
||||||
|
|
||||||
assert len(content["results"]) == 1
|
|
||||||
recording_ids.remove(content["results"][0]["id"])
|
|
||||||
assert recording_ids == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_list_authenticated_distinct():
|
|
||||||
"""A recording for a room with several related users should only be listed once."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
other_user = factories.UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
recording = factories.RecordingFactory(users=[user, other_user])
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert len(content["results"]) == 1
|
|
||||||
assert content["results"][0]["id"] == str(recording.id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_list_ordering_default():
|
|
||||||
"""Recordings should be ordered by descending "updated_at" by default"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
factories.RecordingFactory.create_batch(5, users=[user])
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
results = response.json()["results"]
|
|
||||||
assert len(results) == 5
|
|
||||||
|
|
||||||
# Check that results are sorted by descending "updated_at" as expected
|
|
||||||
for i in range(4):
|
|
||||||
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""
|
|
||||||
Test recordings API endpoints in the Meet core app: save recording.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from ...factories import RecordingFactory
|
|
||||||
from ...models import Recording, RecordingStatusChoices
|
|
||||||
from ...recording.event.exceptions import (
|
|
||||||
InvalidBucketError,
|
|
||||||
InvalidFileTypeError,
|
|
||||||
ParsingEventDataError,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def recording_settings(settings):
|
|
||||||
"""Configure recording-related and storage event Django settings."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_get_parser():
|
|
||||||
"""Mock 'get_parser' factory function."""
|
|
||||||
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
|
||||||
yield mock_parser
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_anonymous(settings, client):
|
|
||||||
"""Anonymous users should not be allowed to save room recordings."""
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
RecordingFactory(status="active")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_wrong_bearer(settings, client):
|
|
||||||
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_permission_needed(settings, client):
|
|
||||||
"""Recordings should not be saved when feature is disabled."""
|
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
|
||||||
"""Test handling of parsing errors in recording event data."""
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
|
||||||
"""Test handling of invalid storage bucket errors in recording event data."""
|
|
||||||
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
|
||||||
"""Test handling of unsupported file types in recording event data."""
|
|
||||||
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
|
||||||
"unsupported '.json'"
|
|
||||||
)
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
|
||||||
"""Test handling of events for non-existent recordings."""
|
|
||||||
|
|
||||||
RecordingFactory(status="active")
|
|
||||||
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "No recording found for this event."}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
|
||||||
)
|
|
||||||
def test_save_recording_non_savable_recording(
|
|
||||||
recording_settings, mock_get_parser, client, status
|
|
||||||
):
|
|
||||||
"""Test that recordings in non-savable states cannot be saved."""
|
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
|
||||||
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.return_value = recording.id
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
|
||||||
" in an error state or has already been saved."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
|
||||||
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
|
||||||
"""Test successful saving of recordings in valid states."""
|
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
|
||||||
|
|
||||||
mock_parser = mock.Mock()
|
|
||||||
mock_parser.get_recording_id.return_value = recording.id
|
|
||||||
mock_get_parser.return_value = mock_parser
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/recordings/storage-hook/",
|
|
||||||
{"recording_data": "valid-data"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"message": "Event processed."}
|
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == RecordingStatusChoices.SAVED
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
Test worker service factories.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0212,W0621,W0613
|
|
||||||
|
|
||||||
from dataclasses import FrozenInstanceError
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.recording.worker.factories import (
|
|
||||||
WorkerService,
|
|
||||||
WorkerServiceConfig,
|
|
||||||
get_worker_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def clear_lru_cache():
|
|
||||||
"""Clear the lru_cache before and after each test"""
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
|
||||||
yield
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_settings():
|
|
||||||
"""Fixture to provide test Django settings"""
|
|
||||||
mocked_settings = {
|
|
||||||
"RECORDING_OUTPUT_FOLDER": "/test/output",
|
|
||||||
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
|
|
||||||
"RECORDING_VERIFY_SSL": True,
|
|
||||||
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
|
|
||||||
"AWS_S3_ACCESS_KEY_ID": "test_key",
|
|
||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
|
||||||
"AWS_S3_REGION_NAME": "test-region",
|
|
||||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use override_settings to properly patch Django settings
|
|
||||||
with override_settings(**mocked_settings):
|
|
||||||
yield test_settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_config(test_settings):
|
|
||||||
"""Fixture to provide a WorkerServiceConfig instance"""
|
|
||||||
return WorkerServiceConfig.from_settings()
|
|
||||||
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
def test_config_initialization(default_config):
|
|
||||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
|
||||||
assert default_config.output_folder == "/test/output"
|
|
||||||
assert default_config.server_configurations == {"server": "test.example.com"}
|
|
||||||
assert default_config.verify_ssl is True
|
|
||||||
assert default_config.bucket_args == {
|
|
||||||
"endpoint": "https://s3.test.com",
|
|
||||||
"access_key": "test_key",
|
|
||||||
"secret": "test_secret",
|
|
||||||
"region": "test-region",
|
|
||||||
"bucket": "test-bucket",
|
|
||||||
"force_path_style": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_immutability(default_config):
|
|
||||||
"""Test that config instances are immutable after creation"""
|
|
||||||
with pytest.raises(FrozenInstanceError):
|
|
||||||
default_config.output_folder = "new/path"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
|
||||||
RECORDING_VERIFY_SSL=True,
|
|
||||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
|
||||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
|
||||||
AWS_S3_REGION_NAME="test-region",
|
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
|
||||||
)
|
|
||||||
def test_config_caching():
|
|
||||||
"""Test that from_settings method caches its result"""
|
|
||||||
# Clear cache before testing caching behavior
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
|
||||||
|
|
||||||
config1 = WorkerServiceConfig.from_settings()
|
|
||||||
config2 = WorkerServiceConfig.from_settings()
|
|
||||||
assert config1 is config2
|
|
||||||
|
|
||||||
|
|
||||||
class MockWorkerService(WorkerService):
|
|
||||||
"""Mock worker service for testing."""
|
|
||||||
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_import_string(monkeypatch):
|
|
||||||
"""Fixture to mock import_string function."""
|
|
||||||
mock = Mock(return_value=MockWorkerService)
|
|
||||||
monkeypatch.setattr("core.recording.worker.factories.import_string", mock)
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
def test_factory_valid_mode(mock_import_string, settings, default_config):
|
|
||||||
"""Test getting worker service with valid mode."""
|
|
||||||
|
|
||||||
settings.RECORDING_WORKER_CLASSES = {
|
|
||||||
"test_mode": "path.to.MockWorkerService",
|
|
||||||
"another_mode": "path.to.AnotherWorkerService",
|
|
||||||
}
|
|
||||||
|
|
||||||
worker = get_worker_service("test_mode")
|
|
||||||
|
|
||||||
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
|
||||||
assert isinstance(worker, MockWorkerService)
|
|
||||||
assert worker.config == default_config
|
|
||||||
|
|
||||||
|
|
||||||
def test_factory_invalid_mode(settings, mock_import_string, default_config):
|
|
||||||
"""Test getting worker service with invalid mode raises ValueError."""
|
|
||||||
|
|
||||||
settings.RECORDING_WORKER_CLASSES = {
|
|
||||||
"test_mode": "path.to.MockWorkerService",
|
|
||||||
"another_mode": "path.to.AnotherWorkerService",
|
|
||||||
}
|
|
||||||
|
|
||||||
worker = get_worker_service("test_mode")
|
|
||||||
|
|
||||||
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
|
||||||
assert isinstance(worker, MockWorkerService)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError) as exc_info:
|
|
||||||
get_worker_service("invalid_mode")
|
|
||||||
mock_import_string.assert_not_called()
|
|
||||||
|
|
||||||
assert "Recording mode 'invalid_mode' not found" in str(exc_info.value)
|
|
||||||
assert "Available modes: ['test_mode', 'another_mode']" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_factory_import_error(mock_import_string, settings):
|
|
||||||
"""Test handling of import errors."""
|
|
||||||
|
|
||||||
mock_import_string.side_effect = ImportError("Module not found")
|
|
||||||
|
|
||||||
settings.RECORDING_WORKER_CLASSES = {
|
|
||||||
"test_mode": "path.to.MockWorkerService",
|
|
||||||
"another_mode": "path.to.AnotherWorkerService",
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ImportError) as exc_info:
|
|
||||||
get_worker_service("test_mode")
|
|
||||||
|
|
||||||
assert "Module not found" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_factory_empty_registry(settings):
|
|
||||||
"""Test behavior when worker registry is empty."""
|
|
||||||
|
|
||||||
settings.RECORDING_WORKER_CLASSES = {}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError) as exc_info:
|
|
||||||
get_worker_service("any_mode")
|
|
||||||
|
|
||||||
assert "Available modes: []" in str(exc_info.value)
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""Test WorkerServiceMediator class."""
|
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613
|
|
||||||
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.factories import RecordingFactory
|
|
||||||
from core.models import RecordingStatusChoices
|
|
||||||
from core.recording.worker.exceptions import (
|
|
||||||
RecordingStartError,
|
|
||||||
RecordingStopError,
|
|
||||||
WorkerConnectionError,
|
|
||||||
WorkerRequestError,
|
|
||||||
WorkerResponseError,
|
|
||||||
)
|
|
||||||
from core.recording.worker.factories import WorkerService
|
|
||||||
from core.recording.worker.mediator import WorkerServiceMediator
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_service():
|
|
||||||
"""Fixture for mock worker service"""
|
|
||||||
return Mock(spec=WorkerService)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mediator(mock_worker_service):
|
|
||||||
"""Fixture for WorkerServiceMediator"""
|
|
||||||
return WorkerServiceMediator(mock_worker_service)
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_success(mediator, mock_worker_service):
|
|
||||||
"""Test successful recording start"""
|
|
||||||
# Setup
|
|
||||||
worker_id = "test-worker-123"
|
|
||||||
mock_worker_service.start.return_value = worker_id
|
|
||||||
|
|
||||||
mock_recording = RecordingFactory(
|
|
||||||
status=RecordingStatusChoices.INITIATED, worker_id=None
|
|
||||||
)
|
|
||||||
mediator.start(mock_recording)
|
|
||||||
|
|
||||||
# Verify worker service call
|
|
||||||
expected_room_name = str(mock_recording.room.id)
|
|
||||||
mock_worker_service.start.assert_called_once_with(
|
|
||||||
expected_room_name, mock_recording.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify recording updates
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.worker_id == worker_id
|
|
||||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
|
||||||
)
|
|
||||||
def test_mediator_start_recording_worker_errors(
|
|
||||||
mediator, mock_worker_service, error_class
|
|
||||||
):
|
|
||||||
"""Test handling of various worker errors during start"""
|
|
||||||
# Setup
|
|
||||||
mock_worker_service.start.side_effect = error_class("Test error")
|
|
||||||
mock_recording = RecordingFactory(
|
|
||||||
status=RecordingStatusChoices.INITIATED, worker_id=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute and verify
|
|
||||||
with pytest.raises(RecordingStartError):
|
|
||||||
mediator.start(mock_recording)
|
|
||||||
|
|
||||||
# Verify recording updates
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
|
||||||
assert mock_recording.worker_id is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
[
|
|
||||||
RecordingStatusChoices.ACTIVE,
|
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
|
||||||
RecordingStatusChoices.STOPPED,
|
|
||||||
RecordingStatusChoices.SAVED,
|
|
||||||
RecordingStatusChoices.ABORTED,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_mediator_start_recording_from_forbidden_status(
|
|
||||||
mediator, mock_worker_service, status
|
|
||||||
):
|
|
||||||
"""Test handling of various worker errors during start"""
|
|
||||||
# Setup
|
|
||||||
mock_recording = RecordingFactory(status=status)
|
|
||||||
|
|
||||||
# Execute and verify
|
|
||||||
with pytest.raises(RecordingStartError):
|
|
||||||
mediator.start(mock_recording)
|
|
||||||
|
|
||||||
# Verify recording was not updated
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.status == status
|
|
||||||
|
|
||||||
|
|
||||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
|
||||||
"""Test successful recording stop"""
|
|
||||||
# Setup
|
|
||||||
mock_recording = RecordingFactory(
|
|
||||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
|
||||||
)
|
|
||||||
mock_worker_service.stop.return_value = "STOPPED"
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
mediator.stop(mock_recording)
|
|
||||||
|
|
||||||
# Verify worker service call
|
|
||||||
mock_worker_service.stop.assert_called_once_with(worker_id=mock_recording.worker_id)
|
|
||||||
|
|
||||||
# Verify recording updates
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.status == RecordingStatusChoices.STOPPED
|
|
||||||
|
|
||||||
|
|
||||||
def test_mediator_stop_recording_aborted(mediator, mock_worker_service):
|
|
||||||
"""Test recording stop when worker returns ABORTED"""
|
|
||||||
# Setup
|
|
||||||
mock_recording = RecordingFactory(
|
|
||||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
|
||||||
)
|
|
||||||
mock_worker_service.stop.return_value = "ABORTED"
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
mediator.stop(mock_recording)
|
|
||||||
|
|
||||||
# Verify recording updates
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.status == RecordingStatusChoices.ABORTED
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("error_class", [WorkerConnectionError, WorkerResponseError])
|
|
||||||
def test_mediator_stop_recording_worker_errors(
|
|
||||||
mediator, mock_worker_service, error_class
|
|
||||||
):
|
|
||||||
"""Test handling of worker errors during stop"""
|
|
||||||
# Setup
|
|
||||||
mock_recording = RecordingFactory(
|
|
||||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
|
||||||
)
|
|
||||||
mock_worker_service.stop.side_effect = error_class("Test error")
|
|
||||||
|
|
||||||
# Execute and verify
|
|
||||||
with pytest.raises(RecordingStopError):
|
|
||||||
mediator.stop(mock_recording)
|
|
||||||
|
|
||||||
# Verify recording updates
|
|
||||||
mock_recording.refresh_from_db()
|
|
||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_STOP
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
"""
|
|
||||||
Test worker service classes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0212,W0621,W0613,E1101
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
|
||||||
from core.recording.worker.factories import WorkerServiceConfig
|
|
||||||
from core.recording.worker.services import (
|
|
||||||
AudioCompositeEgressService,
|
|
||||||
BaseEgressService,
|
|
||||||
VideoCompositeEgressService,
|
|
||||||
livekit_api,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def config():
|
|
||||||
"""Fixture to provide a WorkerServiceConfig instance"""
|
|
||||||
return WorkerServiceConfig(
|
|
||||||
output_folder="/test/output",
|
|
||||||
server_configurations={
|
|
||||||
"host": "test.livekit.io",
|
|
||||||
"api_key": "test_key",
|
|
||||||
"api_secret": "test_secret",
|
|
||||||
},
|
|
||||||
verify_ssl=True,
|
|
||||||
bucket_args={
|
|
||||||
"endpoint": "https://s3.test.com",
|
|
||||||
"access_key": "test_key",
|
|
||||||
"secret": "test_secret",
|
|
||||||
"region": "test-region",
|
|
||||||
"bucket": "test-bucket",
|
|
||||||
"force_path_style": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_s3_upload():
|
|
||||||
"""Fixture for mocked S3Upload"""
|
|
||||||
with patch("core.recording.worker.services.livekit_api.S3Upload") as mock:
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_egress_service():
|
|
||||||
"""Fixture for mocked EgressService"""
|
|
||||||
with patch("core.recording.worker.services.EgressService") as mock:
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(config, mock_s3_upload):
|
|
||||||
"""Fixture for BaseEgressService instance"""
|
|
||||||
return BaseEgressService(config)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_client_session():
|
|
||||||
"""Fixture for mocked aiohttp.ClientSession"""
|
|
||||||
with patch("aiohttp.ClientSession") as mock:
|
|
||||||
mock.return_value.__aenter__ = AsyncMock()
|
|
||||||
mock.return_value.__aexit__ = AsyncMock()
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_tcp_connector():
|
|
||||||
"""Fixture for TCPConnector"""
|
|
||||||
with patch("aiohttp.TCPConnector") as mock_connector:
|
|
||||||
mock_connector_instance = Mock()
|
|
||||||
mock_connector.return_value = mock_connector_instance
|
|
||||||
yield mock_connector
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def video_service(config):
|
|
||||||
"""Fixture for VideoCompositeEgressService"""
|
|
||||||
service = VideoCompositeEgressService(config)
|
|
||||||
service._handle_request = Mock() # Mock the request handler
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def audio_service(config):
|
|
||||||
"""Fixture for AudioCompositeEgressService"""
|
|
||||||
service = AudioCompositeEgressService(config)
|
|
||||||
service._handle_request = Mock() # Mock the request handler
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_initialization(config, mock_s3_upload):
|
|
||||||
"""Test service initialization"""
|
|
||||||
|
|
||||||
service = BaseEgressService(config)
|
|
||||||
assert service._config == config
|
|
||||||
|
|
||||||
mock_s3_upload.assert_called_once_with(
|
|
||||||
endpoint="https://s3.test.com",
|
|
||||||
access_key="test_key",
|
|
||||||
secret="test_secret",
|
|
||||||
region="test-region",
|
|
||||||
bucket="test-bucket",
|
|
||||||
force_path_style=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"filename,extension,expected",
|
|
||||||
[
|
|
||||||
("test", "mp4", "/test/output/test.mp4"),
|
|
||||||
("recording123", "ogg", "/test/output/recording123.ogg"),
|
|
||||||
("live_stream", "m3u8", "/test/output/live_stream.m3u8"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_base_egress_filepath_construction(service, filename, extension, expected):
|
|
||||||
"""Test filepath construction with various inputs"""
|
|
||||||
result = service._get_filepath(filename, extension)
|
|
||||||
assert result == expected
|
|
||||||
assert result.startswith(service._config.output_folder)
|
|
||||||
assert result.endswith(f"{filename}.{extension}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_handle_request_success(
|
|
||||||
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
|
|
||||||
):
|
|
||||||
"""Test successful request handling"""
|
|
||||||
# Setup mock response
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_method = AsyncMock(return_value=mock_response)
|
|
||||||
mock_egress_instance = Mock()
|
|
||||||
mock_egress_instance.test_method = mock_method
|
|
||||||
mock_egress_service.return_value = mock_egress_instance
|
|
||||||
|
|
||||||
# Create test request
|
|
||||||
test_request = Mock()
|
|
||||||
|
|
||||||
response = service._handle_request(test_request, "test_method")
|
|
||||||
|
|
||||||
mock_client_session.assert_called_once_with(
|
|
||||||
connector=mock_tcp_connector.return_value
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify EgressService initialization
|
|
||||||
mock_egress_service.assert_called_once_with(
|
|
||||||
mock_client_session.return_value.__aenter__.return_value,
|
|
||||||
**service._config.server_configurations,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify method call and response
|
|
||||||
mock_method.assert_called_once_with(test_request)
|
|
||||||
assert response == mock_response
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
|
|
||||||
"""Test handling of connection errors"""
|
|
||||||
# Setup mock error
|
|
||||||
mock_method = AsyncMock(
|
|
||||||
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
|
|
||||||
)
|
|
||||||
mock_egress_instance = Mock()
|
|
||||||
mock_egress_instance.test_method = mock_method
|
|
||||||
mock_egress_service.return_value = mock_egress_instance
|
|
||||||
|
|
||||||
# Create test request
|
|
||||||
test_request = Mock()
|
|
||||||
|
|
||||||
# Verify error handling
|
|
||||||
with pytest.raises(WorkerConnectionError) as exc:
|
|
||||||
service._handle_request(test_request, "test_method")
|
|
||||||
|
|
||||||
assert "LiveKit client connection error" in str(exc.value)
|
|
||||||
assert "Connection failed" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"response_status,expected_result",
|
|
||||||
[
|
|
||||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
|
||||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
|
||||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
|
||||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
|
||||||
"""Test stop method with different response statuses"""
|
|
||||||
# Mock _handle_request
|
|
||||||
mock_response = Mock(status=response_status)
|
|
||||||
service._handle_request = Mock(return_value=mock_response)
|
|
||||||
|
|
||||||
# Execute stop
|
|
||||||
result = service.stop("test_worker_id")
|
|
||||||
|
|
||||||
# Verify request and response handling
|
|
||||||
service._handle_request.assert_called_once_with(
|
|
||||||
livekit_api.StopEgressRequest(egress_id="test_worker_id"), "stop_egress"
|
|
||||||
)
|
|
||||||
assert result == expected_result
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_stop_missing_status(service):
|
|
||||||
"""Test stop method when response is missing status"""
|
|
||||||
# Mock _handle_request with missing status
|
|
||||||
mock_response = Mock(status=None)
|
|
||||||
service._handle_request = Mock(return_value=mock_response)
|
|
||||||
|
|
||||||
# Verify error handling
|
|
||||||
with pytest.raises(WorkerResponseError) as exc:
|
|
||||||
service.stop("test_worker_id")
|
|
||||||
|
|
||||||
assert "missing the recording status" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_start_not_implemented(service):
|
|
||||||
"""Test that start method raises NotImplementedError"""
|
|
||||||
with pytest.raises(NotImplementedError) as exc:
|
|
||||||
service.start("test_room", "test_recording")
|
|
||||||
assert "Subclass must implement this method" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("verify_ssl", [True, False])
|
|
||||||
def test_base_egress_ssl_verification_config(verify_ssl):
|
|
||||||
"""Test SSL verification configuration"""
|
|
||||||
config = WorkerServiceConfig(
|
|
||||||
output_folder="/test/output",
|
|
||||||
server_configurations={
|
|
||||||
"host": "test.livekit.io",
|
|
||||||
"api_key": "test_key",
|
|
||||||
"api_secret": "test_secret",
|
|
||||||
},
|
|
||||||
verify_ssl=verify_ssl,
|
|
||||||
bucket_args={
|
|
||||||
"endpoint": "https://s3.test.com",
|
|
||||||
"access_key": "test_key",
|
|
||||||
"secret": "test_secret",
|
|
||||||
"region": "test-region",
|
|
||||||
"bucket": "test-bucket",
|
|
||||||
"force_path_style": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
service = BaseEgressService(config)
|
|
||||||
|
|
||||||
# Mock ClientSession to capture connector configuration
|
|
||||||
with patch("aiohttp.ClientSession") as mock_session:
|
|
||||||
mock_session.return_value.__aenter__ = AsyncMock()
|
|
||||||
mock_session.return_value.__aexit__ = AsyncMock()
|
|
||||||
|
|
||||||
# Trigger request to verify connector configuration
|
|
||||||
service._handle_request(Mock(), "test_method")
|
|
||||||
|
|
||||||
# Verify SSL configuration
|
|
||||||
connector = mock_session.call_args[1]["connector"]
|
|
||||||
assert isinstance(connector, aiohttp.TCPConnector)
|
|
||||||
assert connector._ssl == verify_ssl
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_composite_egress_hrid(video_service):
|
|
||||||
"""Test HRID is correct"""
|
|
||||||
assert video_service.hrid == "video-recording-composite-livekit-egress"
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_composite_egress_start_success(video_service):
|
|
||||||
"""Test successful start of video composite egress"""
|
|
||||||
# Setup mock response
|
|
||||||
egress_id = "test-egress-123"
|
|
||||||
video_service._handle_request.return_value = Mock(egress_id=egress_id)
|
|
||||||
|
|
||||||
# Test parameters
|
|
||||||
room_name = "test-room"
|
|
||||||
recording_id = "rec-123"
|
|
||||||
|
|
||||||
# Call start
|
|
||||||
result = video_service.start(room_name, recording_id)
|
|
||||||
|
|
||||||
# Verify result
|
|
||||||
assert result == egress_id
|
|
||||||
|
|
||||||
# Verify request construction
|
|
||||||
video_service._handle_request.assert_called_once()
|
|
||||||
request = video_service._handle_request.call_args[0][0]
|
|
||||||
method = video_service._handle_request.call_args[0][1]
|
|
||||||
|
|
||||||
# Verify request properties
|
|
||||||
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
|
||||||
assert request.room_name == room_name
|
|
||||||
assert len(request.file_outputs) == 1
|
|
||||||
|
|
||||||
assert not request.audio_only # Video service shouldn't set audio_only
|
|
||||||
|
|
||||||
# Verify file output configuration
|
|
||||||
file_output = request.file_outputs[0]
|
|
||||||
assert file_output.file_type == livekit_api.EncodedFileType.MP4
|
|
||||||
assert file_output.filepath == f"/test/output/{recording_id}.mp4"
|
|
||||||
assert file_output.s3.bucket == "test-bucket"
|
|
||||||
|
|
||||||
# Verify method name
|
|
||||||
assert method == "start_room_composite_egress"
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_composite_egress_start_missing_egress_id(video_service):
|
|
||||||
"""Test handling of missing egress ID in response"""
|
|
||||||
# Setup mock response without egress_id
|
|
||||||
video_service._handle_request.return_value = Mock(egress_id=None)
|
|
||||||
|
|
||||||
with pytest.raises(WorkerResponseError) as exc_info:
|
|
||||||
video_service.start("test-room", "rec-123")
|
|
||||||
|
|
||||||
assert "Egress ID not found" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_audio_composite_egress_hrid(audio_service):
|
|
||||||
"""Test HRID is correct"""
|
|
||||||
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
|
||||||
|
|
||||||
|
|
||||||
def test_audio_composite_egress_start_success(audio_service):
|
|
||||||
"""Test successful start of audio composite egress"""
|
|
||||||
# Setup mock response
|
|
||||||
egress_id = "test-egress-123"
|
|
||||||
audio_service._handle_request.return_value = Mock(egress_id=egress_id)
|
|
||||||
|
|
||||||
# Test parameters
|
|
||||||
room_name = "test-room"
|
|
||||||
recording_id = "rec-123"
|
|
||||||
|
|
||||||
# Call start
|
|
||||||
result = audio_service.start(room_name, recording_id)
|
|
||||||
|
|
||||||
# Verify result
|
|
||||||
assert result == egress_id
|
|
||||||
|
|
||||||
# Verify request construction
|
|
||||||
audio_service._handle_request.assert_called_once()
|
|
||||||
request = audio_service._handle_request.call_args[0][0]
|
|
||||||
method = audio_service._handle_request.call_args[0][1]
|
|
||||||
|
|
||||||
# Verify request properties
|
|
||||||
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
|
||||||
assert request.room_name == room_name
|
|
||||||
assert len(request.file_outputs) == 1
|
|
||||||
assert request.audio_only is True # Audio service should set audio_only
|
|
||||||
|
|
||||||
# Verify file output configuration
|
|
||||||
file_output = request.file_outputs[0]
|
|
||||||
assert file_output.file_type == livekit_api.EncodedFileType.OGG
|
|
||||||
assert file_output.filepath == f"/test/output/{recording_id}.ogg"
|
|
||||||
assert file_output.s3.bucket == "test-bucket"
|
|
||||||
|
|
||||||
# Verify method name
|
|
||||||
assert method == "start_room_composite_egress"
|
|
||||||
|
|
||||||
|
|
||||||
def test_audio_composite_egress_start_missing_egress_id(audio_service):
|
|
||||||
"""Test handling of missing egress ID in response"""
|
|
||||||
# Setup mock response without egress_id
|
|
||||||
audio_service._handle_request.return_value = Mock(egress_id=None)
|
|
||||||
|
|
||||||
with pytest.raises(WorkerResponseError) as exc_info:
|
|
||||||
audio_service.start("test-room", "rec-123")
|
|
||||||
|
|
||||||
assert "Egress ID not found" in str(exc_info.value)
|
|
||||||
@@ -38,7 +38,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||||
"""It should be possible to get a room by its id stripped of its dashes."""
|
"""It should be possible to get a room by its id stripped of its dashes."""
|
||||||
room = RoomFactory(is_public=False)
|
room = RoomFactory(is_public=False)
|
||||||
id_no_dashes = str(room.id)
|
id_no_dashes = str(room.id).replace("-", "")
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/")
|
response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/")
|
||||||
@@ -178,7 +178,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
expected_name = f"{room.id!s}"
|
expected_name = f"{room.id!s}".replace("-", "")
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -220,7 +220,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
expected_name = f"{room.id!s}"
|
expected_name = f"{room.id!s}".replace("-", "")
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -301,8 +301,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
|
||||||
"short_name": user_access.user.short_name,
|
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -312,8 +310,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
|
||||||
"short_name": other_user_access.user.short_name,
|
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -322,7 +318,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
|||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_name = str(room.id)
|
expected_name = str(room.id).replace("-", "")
|
||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
"is_administrable": False,
|
||||||
@@ -378,8 +374,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
|
||||||
"short_name": other_user_access.user.short_name,
|
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -389,8 +383,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
"user": {
|
"user": {
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
|
||||||
"short_name": user_access.user.short_name,
|
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -398,7 +390,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
|||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
expected_name = str(room.id)
|
expected_name = str(room.id).replace("-", "")
|
||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": True,
|
"is_administrable": True,
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
"""
|
|
||||||
Test rooms API endpoints in the Meet core app: start recording.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
|
||||||
from ...models import Recording
|
|
||||||
from ...recording.worker.exceptions import RecordingStartError
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_service():
|
|
||||||
"""Mock worker service."""
|
|
||||||
return mock.Mock()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_service_factory(mock_worker_service):
|
|
||||||
"""Mock worker service factory."""
|
|
||||||
with mock.patch(
|
|
||||||
"core.api.viewsets.get_worker_service",
|
|
||||||
return_value=mock_worker_service,
|
|
||||||
) as mock_worker_service_factory:
|
|
||||||
yield mock_worker_service_factory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_manager(mock_worker_service):
|
|
||||||
"""Mock worker service mediator."""
|
|
||||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
|
||||||
mock_mediator = mock.Mock()
|
|
||||||
mock_mediator_class.return_value = mock_mediator
|
|
||||||
yield mock_mediator
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_anonymous():
|
|
||||||
"""Anonymous users should not be allowed to start room recordings."""
|
|
||||||
room = RoomFactory()
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_non_owner_and_non_administrator():
|
|
||||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_recording_disabled(settings):
|
|
||||||
"""Should fail if recording is disabled for the room."""
|
|
||||||
settings.RECORDING_ENABLE = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_missing_mode(settings):
|
|
||||||
"""Should fail if recording mode is not provided."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/start-recording/", {})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"detail": "Invalid request."}
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_worker_error(
|
|
||||||
mock_worker_service_factory, mock_worker_manager, settings
|
|
||||||
):
|
|
||||||
"""Should handle worker service errors appropriately."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
# Configure mock mediator to raise error
|
|
||||||
mock_start = mock.Mock()
|
|
||||||
mock_start.side_effect = RecordingStartError("Failed to connect to worker")
|
|
||||||
|
|
||||||
mock_worker_manager.start = mock_start
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
assert response.json() == {
|
|
||||||
"error": f"Recording failed to start for room {room.slug}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Recording object should be created even if worker fails
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
recording = Recording.objects.first()
|
|
||||||
assert recording.room == room
|
|
||||||
assert recording.mode == "screen_recording"
|
|
||||||
|
|
||||||
# Verify recording access details
|
|
||||||
assert recording.accesses.count() == 1
|
|
||||||
access = recording.accesses.first()
|
|
||||||
assert access.user == user
|
|
||||||
assert access.role == "owner"
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_success(
|
|
||||||
mock_worker_service_factory, mock_worker_manager, settings
|
|
||||||
):
|
|
||||||
"""Should successfully start recording when everything is configured correctly."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
mock_start = mock.Mock()
|
|
||||||
mock_worker_manager.start = mock_start
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert response.json() == {
|
|
||||||
"message": f"Recording successfully started for room {room.slug}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify the mediator was called with the recording
|
|
||||||
recording = Recording.objects.first()
|
|
||||||
mock_start.assert_called_once_with(recording)
|
|
||||||
|
|
||||||
assert recording.room == room
|
|
||||||
assert recording.mode == "screen_recording"
|
|
||||||
|
|
||||||
# Verify recording access details
|
|
||||||
assert recording.accesses.count() == 1
|
|
||||||
access = recording.accesses.first()
|
|
||||||
assert access.user == user
|
|
||||||
assert access.role == "owner"
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
"""
|
|
||||||
Test rooms API endpoints in the Meet core app: stop recording.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
|
||||||
from ...models import Recording, RecordingStatusChoices
|
|
||||||
from ...recording.worker.exceptions import RecordingStopError
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_service():
|
|
||||||
"""Mock worker service."""
|
|
||||||
return mock.Mock()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_service_factory(mock_worker_service):
|
|
||||||
"""Mock worker service factory."""
|
|
||||||
with mock.patch(
|
|
||||||
"core.api.viewsets.get_worker_service",
|
|
||||||
return_value=mock_worker_service,
|
|
||||||
) as mock_worker_service_factory:
|
|
||||||
yield mock_worker_service_factory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_worker_manager(mock_worker_service):
|
|
||||||
"""Mock worker service mediator."""
|
|
||||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
|
||||||
mock_mediator = mock.Mock()
|
|
||||||
mock_mediator_class.return_value = mock_mediator
|
|
||||||
yield mock_mediator
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_anonymous():
|
|
||||||
"""Anonymous users should not be allowed to stop room recordings."""
|
|
||||||
room = RoomFactory()
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
# Verify recording status hasn't changed
|
|
||||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_non_owner_and_non_administrator():
|
|
||||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
# Verify recording status hasn't changed
|
|
||||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_recording_disabled(settings):
|
|
||||||
"""Should fail if recording is disabled for the room."""
|
|
||||||
|
|
||||||
settings.RECORDING_ENABLE = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
|
||||||
# Verify no recording exists
|
|
||||||
assert Recording.objects.count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_no_active_recording(settings):
|
|
||||||
"""Should fail when there is no active recording for the room."""
|
|
||||||
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "No active recording found for this room."}
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_worker_error(
|
|
||||||
mock_worker_service_factory, mock_worker_manager, settings
|
|
||||||
):
|
|
||||||
"""Should handle worker service errors appropriately."""
|
|
||||||
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
recording = RecordingFactory(
|
|
||||||
room=room,
|
|
||||||
status=RecordingStatusChoices.ACTIVE,
|
|
||||||
mode="screen_recording",
|
|
||||||
)
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
# Configure mock mediator to raise error
|
|
||||||
mock_stop = mock.Mock()
|
|
||||||
mock_stop.side_effect = RecordingStopError("Failed to connect to worker")
|
|
||||||
mock_worker_manager.stop = mock_stop
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
|
||||||
mock_stop.assert_called_once_with(recording)
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
assert response.json() == {
|
|
||||||
"error": f"Recording failed to stop for room {room.slug}"
|
|
||||||
}
|
|
||||||
# Verify recording status hasn't changed
|
|
||||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_recording_success(
|
|
||||||
mock_worker_service_factory, mock_worker_manager, settings
|
|
||||||
):
|
|
||||||
"""Should successfully stop recording when everything is configured correctly."""
|
|
||||||
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
recording = RecordingFactory(
|
|
||||||
room=room,
|
|
||||||
status=RecordingStatusChoices.ACTIVE,
|
|
||||||
mode="screen_recording",
|
|
||||||
)
|
|
||||||
# Make user the room owner
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
|
|
||||||
mock_stop = mock.Mock()
|
|
||||||
mock_worker_manager.stop = mock_stop
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
|
||||||
|
|
||||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
|
||||||
mock_stop.assert_called_once_with(recording)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
|
|
||||||
|
|
||||||
# Verify the recording still exists
|
|
||||||
assert Recording.objects.count() == 1
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
Test for the Analytics class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.analytics import Analytics
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mock_june_analytics")
|
||||||
|
def _mock_june_analytics():
|
||||||
|
with patch("core.analytics.jAnalytics") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_init_enabled(mock_june_analytics):
|
||||||
|
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
|
||||||
|
analytics = Analytics()
|
||||||
|
assert analytics._enabled is True
|
||||||
|
assert mock_june_analytics.write_key == "test_key"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_init_disabled():
|
||||||
|
"""Should disable analytics when ANALYTICS_KEY is not set."""
|
||||||
|
analytics = Analytics()
|
||||||
|
assert analytics._enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_user(mock_june_analytics):
|
||||||
|
"""Should identify a user with the correct traits when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_called_once_with(
|
||||||
|
user_id="12345", traits={"email": "***@example.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_user_with_traits(mock_june_analytics):
|
||||||
|
"""Should identify a user with additional traits when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
|
||||||
|
mock_june_analytics.identify.assert_called_once_with(
|
||||||
|
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_identify_not_enabled(mock_june_analytics):
|
||||||
|
"""Should not call identify when analytics is not enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_no_user(mock_june_analytics):
|
||||||
|
"""Should not call identify when the user is None."""
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(None)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_anonymous_user(mock_june_analytics):
|
||||||
|
"""Should not call identify when the user is anonymous."""
|
||||||
|
user = AnonymousUser()
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_track_event(mock_june_analytics):
|
||||||
|
"""Should track an event with the correct user and event details when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
user_id="12345", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_track_event_not_enabled(mock_june_analytics):
|
||||||
|
"""Should not call track when analytics is not enabled."""
|
||||||
|
user = UserFactory(sub="12345")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
|
||||||
|
mock_june_analytics.track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||||
|
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
|
||||||
|
"""Should track an event with a random anonymous user ID when the user is None."""
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(None, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
mock_uuid4.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||||
|
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
|
||||||
|
"""Should track an event with a random anonymous user ID when the user is anonymous."""
|
||||||
|
user = AnonymousUser()
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
mock_uuid4.assert_called_once()
|
||||||
@@ -96,8 +96,6 @@ def test_api_users_retrieve_me_authenticated():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
|
||||||
"short_name": user.short_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for the Recording model
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.factories import (
|
|
||||||
RecordingFactory,
|
|
||||||
RoomFactory,
|
|
||||||
UserFactory,
|
|
||||||
UserRecordingAccessFactory,
|
|
||||||
)
|
|
||||||
from core.models import Recording, RecordingStatusChoices
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_str():
|
|
||||||
"""The str representation should be the recording ID."""
|
|
||||||
recording = RecordingFactory()
|
|
||||||
assert str(recording) == f"Recording {recording.id} (initiated)"
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_ordering():
|
|
||||||
"""Recordings should be returned ordered by created_at in descending order."""
|
|
||||||
RecordingFactory.create_batch(3)
|
|
||||||
recordings = Recording.objects.all()
|
|
||||||
assert recordings[0].created_at >= recordings[1].created_at
|
|
||||||
assert recordings[1].created_at >= recordings[2].created_at
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_room_relationship():
|
|
||||||
"""It should maintain proper relationship with room."""
|
|
||||||
room = RoomFactory()
|
|
||||||
recording = RecordingFactory(room=room)
|
|
||||||
assert recording.room == room
|
|
||||||
assert recording in room.recordings.all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_default_status():
|
|
||||||
"""A new recording should have INITIATED status by default."""
|
|
||||||
recording = RecordingFactory()
|
|
||||||
assert recording.status == RecordingStatusChoices.INITIATED
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_unique_initiated_or_active_per_room():
|
|
||||||
"""Only one initiated or active recording should be allowed per room."""
|
|
||||||
room = RoomFactory()
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.INITIATED)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_multiple_finished_allowed():
|
|
||||||
"""Multiple finished recordings should be allowed in the same room."""
|
|
||||||
room = RoomFactory()
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
|
||||||
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
|
||||||
assert room.recordings.count() == 2
|
|
||||||
|
|
||||||
|
|
||||||
# Test get_abilities method
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role",
|
|
||||||
["owner", "administrator"],
|
|
||||||
)
|
|
||||||
def test_models_recording_get_abilities_privileges_active(role):
|
|
||||||
"""Test abilities for active recording and privileged user."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
access = UserRecordingAccessFactory(role=role, user=user)
|
|
||||||
access.recording.status = RecordingStatusChoices.ACTIVE
|
|
||||||
abilities = access.recording.get_abilities(user)
|
|
||||||
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False, # Not final status
|
|
||||||
"partial_update": False,
|
|
||||||
"retrieve": True, # Privileged users can always retrieve
|
|
||||||
"stop": True, # Not final status, so can stop
|
|
||||||
"update": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_get_abilities_member_active():
|
|
||||||
"""Test abilities for a user who is unprivileged."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
access = UserRecordingAccessFactory(role="member", user=user)
|
|
||||||
access.recording.status = RecordingStatusChoices.ACTIVE
|
|
||||||
abilities = access.recording.get_abilities(user)
|
|
||||||
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"retrieve": False,
|
|
||||||
"stop": False,
|
|
||||||
"update": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"role",
|
|
||||||
["owner", "administrator"],
|
|
||||||
)
|
|
||||||
def test_models_recording_get_abilities_privileges_final(role):
|
|
||||||
"""Test abilities for active recording and privileged user."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
access = UserRecordingAccessFactory(role=role, user=user)
|
|
||||||
access.recording.status = RecordingStatusChoices.SAVED
|
|
||||||
abilities = access.recording.get_abilities(user)
|
|
||||||
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"partial_update": False,
|
|
||||||
"retrieve": True, # Privileged users can always retrieve
|
|
||||||
"stop": False, # In final status, so can not stop
|
|
||||||
"update": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_get_abilities_member_final():
|
|
||||||
"""Test abilities for a user who is unprivileged."""
|
|
||||||
|
|
||||||
user = UserFactory()
|
|
||||||
access = UserRecordingAccessFactory(role="member", user=user)
|
|
||||||
access.recording.status = RecordingStatusChoices.SAVED
|
|
||||||
abilities = access.recording.get_abilities(user)
|
|
||||||
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"retrieve": False,
|
|
||||||
"stop": False,
|
|
||||||
"update": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Test is_savable method
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_is_savable_normal():
|
|
||||||
"""Test is_savable for normal recording status."""
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
|
||||||
assert recording.is_savable() is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
[
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
|
||||||
RecordingStatusChoices.ABORTED,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_models_recording_is_savable_error(status):
|
|
||||||
"""Test is_savable for error status."""
|
|
||||||
recording = RecordingFactory(status=status)
|
|
||||||
assert recording.is_savable() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_is_savable_already_saved():
|
|
||||||
"""Test is_savable for already saved recording."""
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
|
||||||
assert recording.is_savable() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_is_savable_only_initiated():
|
|
||||||
"""Test is_savable for only initiated recording."""
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
|
||||||
assert recording.is_savable() is False
|
|
||||||
|
|
||||||
|
|
||||||
# Test few corner cases
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_worker_id_optional():
|
|
||||||
"""Worker ID should be optional."""
|
|
||||||
recording = RecordingFactory(worker_id=None)
|
|
||||||
assert recording.worker_id is None
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-123")
|
|
||||||
assert recording.worker_id == "worker-123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_invalid_status():
|
|
||||||
"""Test that setting an invalid status raises an error."""
|
|
||||||
recording = RecordingFactory()
|
|
||||||
recording.status = "INVALID_STATUS"
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_room_deletion():
|
|
||||||
"""Test that deleting a room cascades to its recordings."""
|
|
||||||
room = RoomFactory()
|
|
||||||
recording = RecordingFactory(room=room)
|
|
||||||
room.delete()
|
|
||||||
assert not Recording.objects.filter(id=recording.id).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_worker_id_very_long():
|
|
||||||
"""Test worker_id with maximum length."""
|
|
||||||
long_id = "w" * 255
|
|
||||||
recording = RecordingFactory(worker_id=long_id)
|
|
||||||
assert recording.worker_id == long_id
|
|
||||||
|
|
||||||
too_long_id = "w" * 256
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
RecordingFactory(worker_id=too_long_id)
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for the RecordingAccess model
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_str():
|
|
||||||
"""
|
|
||||||
The str representation should include user email, recording ID and role.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory(email="david.bowman@example.com")
|
|
||||||
access = factories.UserRecordingAccessFactory(
|
|
||||||
role="member",
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
str(access)
|
|
||||||
== f"david.bowman@example.com is member in Recording {access.recording.id} (initiated)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_unique_user():
|
|
||||||
"""Recording accesses should be unique for a given couple of user and recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory()
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ValidationError,
|
|
||||||
match="This user is already in this recording.",
|
|
||||||
):
|
|
||||||
factories.UserRecordingAccessFactory(
|
|
||||||
user=access.user, recording=access.recording
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_several_empty_teams():
|
|
||||||
"""A recording can have several recording accesses with an empty team."""
|
|
||||||
access = factories.UserRecordingAccessFactory()
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_unique_team():
|
|
||||||
"""Recording accesses should be unique for a given couple of team and recording."""
|
|
||||||
access = factories.TeamRecordingAccessFactory()
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ValidationError,
|
|
||||||
match="This team is already in this recording.",
|
|
||||||
):
|
|
||||||
factories.TeamRecordingAccessFactory(
|
|
||||||
team=access.team, recording=access.recording
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_several_null_users():
|
|
||||||
"""A recording can have several recording accesses with a null user."""
|
|
||||||
access = factories.TeamRecordingAccessFactory()
|
|
||||||
factories.TeamRecordingAccessFactory(recording=access.recording)
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_user_and_team_set():
|
|
||||||
"""User and team can't both be set on a recording access."""
|
|
||||||
with pytest.raises(
|
|
||||||
ValidationError,
|
|
||||||
match="Either user or team must be set, not both.",
|
|
||||||
):
|
|
||||||
factories.UserRecordingAccessFactory(team="my-team")
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_accesses_user_and_team_empty():
|
|
||||||
"""User and team can't both be empty on a recording access."""
|
|
||||||
with pytest.raises(
|
|
||||||
ValidationError,
|
|
||||||
match="Either user or team must be set, not both.",
|
|
||||||
):
|
|
||||||
factories.UserRecordingAccessFactory(user=None)
|
|
||||||
|
|
||||||
|
|
||||||
# get_abilities
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_anonymous():
|
|
||||||
"""Check abilities returned for an anonymous user."""
|
|
||||||
access = factories.UserRecordingAccessFactory()
|
|
||||||
abilities = access.get_abilities(AnonymousUser())
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": False,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_authenticated():
|
|
||||||
"""Check abilities returned for an authenticated user."""
|
|
||||||
access = factories.UserRecordingAccessFactory()
|
|
||||||
user = factories.UserFactory()
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": False,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# - for owner
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_owner_of_self_allowed():
|
|
||||||
"""
|
|
||||||
Check abilities of self access for the owner of a recording when
|
|
||||||
there is more than one owner left.
|
|
||||||
"""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="owner")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording, role="owner")
|
|
||||||
abilities = access.get_abilities(access.user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["administrator", "member"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_owner_of_self_last():
|
|
||||||
"""
|
|
||||||
Check abilities of self access for the owner of a recording when there is only one owner left.
|
|
||||||
"""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="owner")
|
|
||||||
abilities = access.get_abilities(access.user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_owner_of_owner():
|
|
||||||
"""Check abilities of owner access for the owner of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="owner")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="owner"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["administrator", "member"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_owner_of_administrator():
|
|
||||||
"""Check abilities of administrator access for the owner of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="owner"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["member", "owner"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_owner_of_member():
|
|
||||||
"""Check abilities of member access for the owner of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="member")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="owner"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["administrator", "owner"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# - for administrator
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_administrator_of_owner():
|
|
||||||
"""Check abilities of owner access for the administrator of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="owner")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="administrator"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_administrator_of_administrator():
|
|
||||||
"""Check abilities of administrator access for the administrator of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="administrator"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["member"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_administrator_of_member():
|
|
||||||
"""Check abilities of member access for the administrator of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="member")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="administrator"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": True,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": True,
|
|
||||||
"partial_update": True,
|
|
||||||
"set_role_to": ["administrator"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# - for member
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_member_of_owner():
|
|
||||||
"""Check abilities of owner access for the member of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="owner")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="member"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_member_of_administrator():
|
|
||||||
"""Check abilities of administrator access for the member of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="member"
|
|
||||||
).user
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_recording_access_get_abilities_for_member_of_member_user(
|
|
||||||
django_assert_num_queries,
|
|
||||||
):
|
|
||||||
"""Check abilities of member access for the member of a recording."""
|
|
||||||
access = factories.UserRecordingAccessFactory(role="member")
|
|
||||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
|
||||||
user = factories.UserRecordingAccessFactory(
|
|
||||||
recording=access.recording, role="member"
|
|
||||||
).user
|
|
||||||
|
|
||||||
with django_assert_num_queries(1):
|
|
||||||
abilities = access.get_abilities(user)
|
|
||||||
|
|
||||||
assert abilities == {
|
|
||||||
"destroy": False,
|
|
||||||
"retrieve": True,
|
|
||||||
"update": False,
|
|
||||||
"partial_update": False,
|
|
||||||
"set_role_to": [],
|
|
||||||
}
|
|
||||||
@@ -44,3 +44,12 @@ def test_models_users_send_mail_main_missing():
|
|||||||
user.email_user("my subject", "my message")
|
user.email_user("my subject", "my message")
|
||||||
|
|
||||||
assert str(excinfo.value) == "User has no email address."
|
assert str(excinfo.value) == "User has no email address."
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_users_email_anonymized():
|
||||||
|
"""The user's email should be anonymized if it exists."""
|
||||||
|
user = factories.UserFactory(email="john.doe@world.com")
|
||||||
|
assert user.email_anonymized == "***@world.com"
|
||||||
|
|
||||||
|
user = factories.UserFactory(email=None)
|
||||||
|
assert user.email_anonymized == ""
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ from django.urls import include, path
|
|||||||
|
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import viewsets
|
||||||
from core.authentication.urls import urlpatterns as oidc_urls
|
from core.authentication.urls import urlpatterns as oidc_urls
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("users", viewsets.UserViewSet, basename="users")
|
router.register("users", viewsets.UserViewSet, basename="users")
|
||||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
|
||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
@@ -24,7 +23,6 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
room_admin=True,
|
room_admin=True,
|
||||||
|
room_record=True,
|
||||||
can_update_own_metadata=True,
|
can_update_own_metadata=True,
|
||||||
can_publish_sources=[
|
can_publish_sources=[
|
||||||
"camera",
|
"camera",
|
||||||
|
|||||||
@@ -119,25 +119,6 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Media
|
|
||||||
AWS_S3_ENDPOINT_URL = values.Value(
|
|
||||||
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
|
||||||
)
|
|
||||||
AWS_S3_ACCESS_KEY_ID = values.Value(
|
|
||||||
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
|
||||||
)
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
|
||||||
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
|
||||||
)
|
|
||||||
AWS_S3_REGION_NAME = values.Value(
|
|
||||||
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
|
||||||
)
|
|
||||||
AWS_STORAGE_BUCKET_NAME = values.Value(
|
|
||||||
"meet-media-storage",
|
|
||||||
environ_name="AWS_STORAGE_BUCKET_NAME",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||||
|
|
||||||
@@ -270,19 +251,6 @@ class Base(Configuration):
|
|||||||
"REDOC_DIST": "SIDECAR",
|
"REDOC_DIST": "SIDECAR",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend
|
|
||||||
FRONTEND_CONFIGURATION = {
|
|
||||||
"analytics": values.DictValue(
|
|
||||||
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
|
|
||||||
),
|
|
||||||
"support": values.DictValue(
|
|
||||||
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
|
|
||||||
),
|
|
||||||
"silence_livekit_debug_logs": values.BooleanValue(
|
|
||||||
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
||||||
EMAIL_HOST = values.Value(None)
|
EMAIL_HOST = values.Value(None)
|
||||||
@@ -290,7 +258,6 @@ class Base(Configuration):
|
|||||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
|
||||||
EMAIL_FROM = values.Value("from@example.com")
|
EMAIL_FROM = values.Value("from@example.com")
|
||||||
|
|
||||||
AUTH_USER_MODEL = "core.User"
|
AUTH_USER_MODEL = "core.User"
|
||||||
@@ -327,10 +294,6 @@ class Base(Configuration):
|
|||||||
default=True,
|
default=True,
|
||||||
environ_name="OIDC_CREATE_USER",
|
environ_name="OIDC_CREATE_USER",
|
||||||
)
|
)
|
||||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
|
||||||
default=False,
|
|
||||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
|
||||||
)
|
|
||||||
OIDC_RP_SIGN_ALGO = values.Value(
|
OIDC_RP_SIGN_ALGO = values.Value(
|
||||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -390,16 +353,6 @@ class Base(Configuration):
|
|||||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||||
)
|
)
|
||||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
|
||||||
default=["given_name", "usual_name"],
|
|
||||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
|
||||||
default="given_name",
|
|
||||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Video conference configuration
|
# Video conference configuration
|
||||||
LIVEKIT_CONFIGURATION = {
|
LIVEKIT_CONFIGURATION = {
|
||||||
@@ -409,44 +362,14 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||||
}
|
}
|
||||||
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
|
DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue(
|
||||||
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
|
True, environ_name="DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None
|
||||||
)
|
)
|
||||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
ANALYTICS_KEY = values.Value(
|
||||||
# Recording settings
|
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
|
||||||
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
|
||||||
)
|
|
||||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
|
||||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
|
||||||
)
|
|
||||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
|
||||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
|
||||||
)
|
|
||||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
|
||||||
{
|
|
||||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
|
||||||
"transcript": "core.recording.worker.services.AudioCompositeEgressService",
|
|
||||||
},
|
|
||||||
environ_name="RECORDING_WORKER_CLASSES",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
|
||||||
"core.recording.event.parsers.MinioParser",
|
|
||||||
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
|
||||||
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
|
||||||
)
|
|
||||||
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
|
||||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
|
||||||
)
|
|
||||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
|
||||||
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -568,6 +491,8 @@ class Test(Base):
|
|||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||||
|
|
||||||
|
ANALYTICS_KEY = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
@@ -610,14 +535,6 @@ class Production(Base):
|
|||||||
#
|
#
|
||||||
# In other cases, you should comment the following line to avoid security issues.
|
# In other cases, you should comment the following line to avoid security issues.
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
SECURE_HSTS_SECONDS = 60
|
|
||||||
SECURE_HSTS_PRELOAD = True
|
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
||||||
SECURE_SSL_REDIRECT = True
|
|
||||||
SECURE_REDIRECT_EXEMPT = [
|
|
||||||
"^__lbheartbeat__",
|
|
||||||
"^__heartbeat__",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|||||||
+11
-12
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.8"
|
version = "0.1.5"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.35.19",
|
"boto3==1.35.10",
|
||||||
"Brotli==1.1.0",
|
"Brotli==1.1.0",
|
||||||
"celery[redis]==5.4.0",
|
"celery[redis]==5.4.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
@@ -36,28 +36,28 @@ dependencies = [
|
|||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
"django-storages[s3]==1.14.4",
|
"django-storages[s3]==1.14.4",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.1.1",
|
"django==5.1",
|
||||||
"djangorestframework==3.15.2",
|
"djangorestframework==3.15.2",
|
||||||
"drf_spectacular==0.27.2",
|
"drf_spectacular==0.27.2",
|
||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.10",
|
"easy_thumbnails==2.9",
|
||||||
"factory_boy==3.3.1",
|
"factory_boy==3.3.1",
|
||||||
|
"freezegun==1.5.1",
|
||||||
"gunicorn==23.0.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.23.0",
|
"jsonschema==4.23.0",
|
||||||
"june-analytics-python==2.3.0",
|
"june-analytics-python==2.3.0",
|
||||||
"markdown==3.7",
|
"markdown==3.7",
|
||||||
"nested-multipart-parser==1.5.0",
|
"nested-multipart-parser==1.5.0",
|
||||||
"psycopg[binary]==3.2.2",
|
"psycopg[binary]==3.2.1",
|
||||||
"PyJWT==2.9.0",
|
"PyJWT==2.9.0",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"sentry-sdk==2.14.0",
|
"sentry-sdk==2.13.0",
|
||||||
"url-normalize==1.4.3",
|
"url-normalize==1.4.3",
|
||||||
"WeasyPrint>=60.2",
|
"WeasyPrint>=60.2",
|
||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.7.0",
|
"livekit-api==0.7.0",
|
||||||
"aiohttp==3.10.10",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -70,20 +70,19 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2024.7.1",
|
"drf-spectacular-sidecar==2024.7.1",
|
||||||
"freezegun==1.5.1",
|
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.27.0",
|
"ipython==8.27.0",
|
||||||
"pyfakefs==5.6.0",
|
"pyfakefs==5.6.0",
|
||||||
"pylint-django==2.5.5",
|
"pylint-django==2.5.5",
|
||||||
"pylint==3.2.7",
|
"pylint==3.2.7",
|
||||||
"pytest-cov==5.0.0",
|
"pytest-cov==5.0.0",
|
||||||
"pytest-django==4.9.0",
|
"pytest-django==4.8.0",
|
||||||
"pytest==8.3.3",
|
"pytest==8.3.2",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.6.1",
|
"pytest-xdist==3.6.1",
|
||||||
"responses==0.25.3",
|
"responses==0.25.3",
|
||||||
"ruff==0.6.5",
|
"ruff==0.6.3",
|
||||||
"types-requests==2.32.0.20240914",
|
"types-requests==2.32.0.20240712",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine as frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ COPY .dockerignore ./.dockerignore
|
|||||||
COPY ./src/frontend/ .
|
COPY ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps AS meet
|
FROM frontend-deps as meet
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
FROM frontend-deps AS meet-dev
|
FROM frontend-deps as meet-dev
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ CMD [ "npm", "run", "dev"]
|
|||||||
|
|
||||||
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
||||||
# to avoid rebuilding the app at every changes.
|
# to avoid rebuilding the app at every changes.
|
||||||
FROM meet AS meet-builder
|
FROM meet as meet-builder
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Visio</title>
|
<title>Meet</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2842
-2569
File diff suppressed because it is too large
Load Diff
+29
-32
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.8",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -13,48 +13,45 @@
|
|||||||
"check": "prettier --check ./src"
|
"check": "prettier --check ./src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.6.5",
|
"@livekit/components-react": "2.3.3",
|
||||||
"@livekit/components-styles": "1.1.3",
|
"@livekit/components-styles": "1.0.12",
|
||||||
"@livekit/track-processors": "0.3.2",
|
"@pandacss/preset-panda": "0.41.0",
|
||||||
"@pandacss/preset-panda": "0.46.1",
|
"@react-aria/toast": "3.0.0-beta.15",
|
||||||
"@react-aria/toast": "3.0.0-beta.16",
|
|
||||||
"@remixicon/react": "4.2.0",
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.59.4",
|
"@tanstack/react-query": "5.49.2",
|
||||||
"crisp-sdk-web": "1.0.25",
|
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
"i18next": "23.15.2",
|
"i18next": "23.12.1",
|
||||||
"i18next-browser-languagedetector": "8.0.0",
|
"i18next-browser-languagedetector": "8.0.0",
|
||||||
"i18next-parser": "9.0.2",
|
"i18next-parser": "9.0.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.5.7",
|
"livekit-client": "2.3.1",
|
||||||
"posthog-js": "1.186.1",
|
"react": "18.2.0",
|
||||||
"react": "18.3.1",
|
"react-aria-components": "1.2.1",
|
||||||
"react-aria-components": "1.4.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dom": "18.3.1",
|
"react-i18next": "14.1.3",
|
||||||
"react-i18next": "15.0.2",
|
|
||||||
"use-sound": "4.0.3",
|
"use-sound": "4.0.3",
|
||||||
"valtio": "2.0.0",
|
"valtio": "1.13.2",
|
||||||
"wouter": "3.3.5"
|
"wouter": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "0.46.1",
|
"@pandacss/dev": "0.41.0",
|
||||||
"@tanstack/eslint-plugin-query": "5.59.2",
|
"@tanstack/eslint-plugin-query": "5.49.1",
|
||||||
"@tanstack/react-query-devtools": "5.59.4",
|
"@tanstack/react-query-devtools": "5.49.2",
|
||||||
"@types/node": "20.16.11",
|
"@types/node": "20.14.9",
|
||||||
"@types/react": "18.3.11",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.8.1",
|
"@typescript-eslint/eslint-plugin": "7.13.1",
|
||||||
"@typescript-eslint/parser": "8.8.1",
|
"@typescript-eslint/parser": "7.13.1",
|
||||||
"@vitejs/plugin-react": "4.3.2",
|
"@vitejs/plugin-react": "4.3.1",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||||
"eslint-plugin-react-hooks": "4.6.2",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-refresh": "0.4.12",
|
"eslint-plugin-react-refresh": "0.4.7",
|
||||||
"postcss": "8.4.47",
|
"postcss": "8.4.39",
|
||||||
"prettier": "3.3.3",
|
"prettier": "3.3.3",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.5.2",
|
||||||
"vite": "5.4.8",
|
"vite": "5.3.1",
|
||||||
"vite-tsconfig-paths": "5.0.1"
|
"vite-tsconfig-paths": "4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const config: Config = {
|
|||||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
xs: '22.6em', // 360px (we assume less than that are old/entry level mobile phones)
|
||||||
xsm: '31.25em', // 500px,
|
|
||||||
sm: '40em', // 640px
|
sm: '40em', // 640px
|
||||||
md: '48em', // 768px
|
md: '48em', // 768px
|
||||||
lg: '64em', // 1024px
|
lg: '64em', // 1024px
|
||||||
@@ -65,16 +64,16 @@ const config: Config = {
|
|||||||
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
||||||
},
|
},
|
||||||
active_speaker: {
|
active_speaker: {
|
||||||
'0%': { height: '25%' },
|
'0%': { height: '4px' },
|
||||||
'25%': { height: '45%' },
|
'25%': { height: '8px' },
|
||||||
'50%': { height: '20%' },
|
'50%': { height: '6px' },
|
||||||
'100%': { height: '55%' },
|
'100%': { height: '16px' },
|
||||||
},
|
},
|
||||||
active_speaker_small: {
|
active_speake_small: {
|
||||||
'0%': { height: '20%' },
|
'0%': { height: '4px' },
|
||||||
'25%': { height: '25%' },
|
'25%': { height: '6px' },
|
||||||
'50%': { height: '18%' },
|
'50%': { height: '4px' },
|
||||||
'100%': { height: '25%' },
|
'100%': { height: '8px' },
|
||||||
},
|
},
|
||||||
wave_hand: {
|
wave_hand: {
|
||||||
'0%': { transform: 'rotate(0deg)' },
|
'0%': { transform: 'rotate(0deg)' },
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import { Layout } from './layout/Layout'
|
|||||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
import './i18n/init'
|
import './i18n/init'
|
||||||
|
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
useLang(i18n.language)
|
useLang(i18n.language)
|
||||||
|
|
||||||
|
const isProduction = import.meta.env.PROD
|
||||||
|
silenceLiveKitLogs(isProduction)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AppInitialization />
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<I18nProvider locale={i18n.language}>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const keys = {
|
export const keys = {
|
||||||
user: 'user',
|
user: 'user',
|
||||||
room: 'room',
|
room: 'room',
|
||||||
config: 'config',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { fetchApi } from './fetchApi'
|
|
||||||
import { keys } from './queryKeys'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
// todo - refactor it in a proper place
|
|
||||||
export enum RecordingMode {
|
|
||||||
Transcript = 'transcript',
|
|
||||||
ScreenRecording = 'screen_recording',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiConfig {
|
|
||||||
analytics?: {
|
|
||||||
id: string
|
|
||||||
host: string
|
|
||||||
}
|
|
||||||
support?: {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
silence_livekit_debug_logs?: boolean
|
|
||||||
recording?: {
|
|
||||||
is_enabled?: boolean
|
|
||||||
available_modes?: RecordingMode[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
|
||||||
return fetchApi<ApiConfig>(`config/`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useConfig = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [keys.config],
|
|
||||||
queryFn: fetchConfig,
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 635 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 702 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 599 KiB |
@@ -1,20 +0,0 @@
|
|||||||
import { silenceLiveKitLogs } from '@/utils/livekit'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
|
||||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
|
||||||
|
|
||||||
export const AppInitialization = () => {
|
|
||||||
const { data } = useConfig()
|
|
||||||
|
|
||||||
const {
|
|
||||||
analytics = {},
|
|
||||||
support = {},
|
|
||||||
silence_livekit_debug_logs = false,
|
|
||||||
} = data || {}
|
|
||||||
|
|
||||||
useAnalytics(analytics)
|
|
||||||
useSupport(support)
|
|
||||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiExternalLinkLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const Feedback = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||||
|
variant="success"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
|
||||||
|
💡
|
||||||
|
</span>
|
||||||
|
{t('feedbackAlert')}
|
||||||
|
<RiExternalLinkLine
|
||||||
|
size={16}
|
||||||
|
className={css({ marginLeft: 0.5 })}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Text, A } from '@/primitives'
|
|
||||||
|
|
||||||
const GRIST_FORM =
|
|
||||||
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
|
|
||||||
|
|
||||||
export const FeedbackBanner = () => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: '#E8EDFF',
|
|
||||||
color: '#0063CB',
|
|
||||||
display: { base: 'none', sm: 'flex' },
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '0.5rem 0',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'inline-flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiErrorWarningLine size={20} />
|
|
||||||
<Text as="p">{t('feedback.context')}</Text>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.25,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<A href={GRIST_FORM} target="_blank">
|
|
||||||
{t('feedback.cta')}
|
|
||||||
</A>
|
|
||||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,39 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useLocation } from 'wouter'
|
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
|
||||||
|
|
||||||
export const startAnalyticsSession = (data: ApiUser) => {
|
|
||||||
if (posthog._isIdentified()) return
|
|
||||||
const { id, email } = data
|
|
||||||
posthog.identify(id, { email })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const terminateAnalyticsSession = () => {
|
|
||||||
if (!posthog._isIdentified()) return
|
|
||||||
posthog.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
export type useAnalyticsProps = {
|
|
||||||
id?: string
|
|
||||||
host?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
|
||||||
const [location] = useLocation()
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id || !host) return
|
|
||||||
if (posthog.__loaded) return
|
|
||||||
posthog.init(id, {
|
|
||||||
api_host: host,
|
|
||||||
person_profiles: 'always',
|
|
||||||
})
|
|
||||||
}, [id, host])
|
|
||||||
|
|
||||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
|
||||||
useEffect(() => {
|
|
||||||
posthog.capture('$pageview')
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
|
||||||
last_name: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
|
|||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// so that consumers of fetchUser don't think the work already ended
|
||||||
if (canAttemptSilentLogin()) {
|
if (canAttemptSilentLogin()) {
|
||||||
attemptSilentLogin(300)
|
attemptSilentLogin(3600)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { fetchUser } from './fetchUser'
|
import { fetchUser } from './fetchUser'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
|
||||||
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns info about currently logged-in user
|
* returns info about currently logged in user
|
||||||
*
|
*
|
||||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||||
*/
|
*/
|
||||||
@@ -15,16 +12,9 @@ export const useUser = () => {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [keys.user],
|
queryKey: [keys.user],
|
||||||
queryFn: fetchUser,
|
queryFn: fetchUser,
|
||||||
staleTime: Infinity,
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (query?.data) {
|
|
||||||
startAnalyticsSession(query.data)
|
|
||||||
initializeSupportSession(query.data)
|
|
||||||
}
|
|
||||||
}, [query.data])
|
|
||||||
|
|
||||||
const isLoggedIn =
|
const isLoggedIn =
|
||||||
query.status === 'success' ? query.data !== false : undefined
|
query.status === 'success' ? query.data !== false : undefined
|
||||||
const isLoggedOut = isLoggedIn === false
|
const isLoggedOut = isLoggedIn === false
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import firstSlide from '@/assets/intro-slider/1_solo.png'
|
|
||||||
import secondSlide from '@/assets/intro-slider/2_multiple.png'
|
|
||||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
|
||||||
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Button, LinkButton } from '@/primitives'
|
|
||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
// todo - extract in a proper env variable
|
|
||||||
const BETA_USERS_FORM_URL =
|
|
||||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
|
||||||
|
|
||||||
const Heading = styled('h2', {
|
|
||||||
base: {
|
|
||||||
width: 'fit-content',
|
|
||||||
marginBottom: 0,
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
marginTop: '0.75rem',
|
|
||||||
lineHeight: '2rem',
|
|
||||||
maxWidth: '23rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
textWrap: 'pretty',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Body = styled('p', {
|
|
||||||
base: {
|
|
||||||
maxWidth: '23rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
textWrap: 'pretty',
|
|
||||||
lineHeight: '1.2rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Image = styled('img', {
|
|
||||||
base: {
|
|
||||||
maxHeight: '362px',
|
|
||||||
height: '100%',
|
|
||||||
width: 'fit-content',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Dot = styled('div', {
|
|
||||||
base: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'inline-block',
|
|
||||||
height: '.375rem',
|
|
||||||
margin: '0 .25rem',
|
|
||||||
width: '.375rem',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
selected: {
|
|
||||||
true: {
|
|
||||||
backgroundColor: '#000091',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
backgroundColor: '#CACAFB',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ButtonContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: { base: 'none', xsm: 'block' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ButtonVerticalCenter = styled('div', {
|
|
||||||
base: {
|
|
||||||
marginTop: '13.3125rem',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const SlideContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
alignItems: 'stretch',
|
|
||||||
display: 'flex',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Slide = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
justifyContent: 'start',
|
|
||||||
minHeight: { base: 'none', xsm: '550px' },
|
|
||||||
minWidth: { base: 'none', xsm: '200px' },
|
|
||||||
width: { base: '100%', xsm: '22.625rem' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
visible: {
|
|
||||||
true: {
|
|
||||||
visibility: 'visible',
|
|
||||||
position: 'static',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
visibility: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TextAnimation = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.5rem',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
visible: {
|
|
||||||
true: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'none',
|
|
||||||
transition: 'opacity ease-in .3s, transform ease-in .3s',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateX(-30%)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type Slide = {
|
|
||||||
key: string
|
|
||||||
img: string
|
|
||||||
isAvailableInBeta?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo - optimize how images are imported
|
|
||||||
const SLIDES: Slide[] = [
|
|
||||||
{
|
|
||||||
key: 'slide1',
|
|
||||||
img: firstSlide,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'slide2',
|
|
||||||
img: secondSlide,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'slide3',
|
|
||||||
img: thirdSlide,
|
|
||||||
isAvailableInBeta: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const IntroSlider = () => {
|
|
||||||
const [slideIndex, setSlideIndex] = useState(0)
|
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
|
||||||
const NUMBER_SLIDES = SLIDES.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ButtonContainer>
|
|
||||||
<ButtonVerticalCenter>
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
aria-label={t('previous.label')}
|
|
||||||
tooltip={t('previous.tooltip')}
|
|
||||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
|
||||||
isDisabled={slideIndex == 0}
|
|
||||||
>
|
|
||||||
<RiArrowLeftSLine />
|
|
||||||
</Button>
|
|
||||||
</ButtonVerticalCenter>
|
|
||||||
</ButtonContainer>
|
|
||||||
<SlideContainer>
|
|
||||||
{SLIDES.map((slide, index) => (
|
|
||||||
<Slide visible={index == slideIndex} key={index}>
|
|
||||||
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
|
|
||||||
<TextAnimation visible={index == slideIndex}>
|
|
||||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
|
||||||
<Body>{t(`${slide.key}.body`)}</Body>
|
|
||||||
{slide.isAvailableInBeta && (
|
|
||||||
<LinkButton
|
|
||||||
href={BETA_USERS_FORM_URL}
|
|
||||||
tooltip={t('beta.tooltip')}
|
|
||||||
variant={'primary'}
|
|
||||||
size={'sm'}
|
|
||||||
style={{ marginTop: '1rem', width: 'fit-content' }}
|
|
||||||
>
|
|
||||||
{t('beta.text')}
|
|
||||||
</LinkButton>
|
|
||||||
)}
|
|
||||||
</TextAnimation>
|
|
||||||
</Slide>
|
|
||||||
))}
|
|
||||||
</SlideContainer>
|
|
||||||
<ButtonContainer>
|
|
||||||
<ButtonVerticalCenter>
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
aria-label={t('next.label')}
|
|
||||||
tooltip={t('next.tooltip')}
|
|
||||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
|
||||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
|
||||||
>
|
|
||||||
<RiArrowRightSLine />
|
|
||||||
</Button>
|
|
||||||
</ButtonVerticalCenter>
|
|
||||||
</ButtonContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
display: { base: 'none', xsm: 'block' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{SLIDES.map((_, index) => (
|
|
||||||
<Dot key={index} selected={index == slideIndex} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
|
|||||||
name="roomId"
|
name="roomId"
|
||||||
label={t('joinInputLabel')}
|
label={t('joinInputLabel')}
|
||||||
description={t('joinInputExample', {
|
description={t('joinInputExample', {
|
||||||
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf',
|
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
||||||
})}
|
})}
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
return !isRoomValid(value.trim()) ? (
|
return !isRoomValid(value.trim()) ? (
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const LaterMeetingDialog = ({
|
|||||||
setIsCopied(true)
|
setIsCopied(true)
|
||||||
}}
|
}}
|
||||||
onHoverChange={setIsHovered}
|
onHoverChange={setIsHovered}
|
||||||
data-attr="later-dialog-copy"
|
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { A, Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const MANIFEST_LINK =
|
|
||||||
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
|
|
||||||
|
|
||||||
export const MoreLink = () => {
|
|
||||||
const { t } = useTranslation('home')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
|
||||||
<A
|
|
||||||
href={MANIFEST_LINK}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={t('moreLinkLabel')}
|
|
||||||
>
|
|
||||||
{t('moreLink')}
|
|
||||||
</A>{' '}
|
|
||||||
{t('moreAbout')}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,144 +1,20 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
import { DialogTrigger } from 'react-aria-components'
|
||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu, Text } from '@/primitives'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Centered } from '@/layout/Centered'
|
||||||
import { generateRoomId } from '@/features/rooms'
|
import { generateRoomId } from '@/features/rooms'
|
||||||
import { useUser, UserAware } from '@/features/auth'
|
import { authUrl, useUser, UserAware } from '@/features/auth'
|
||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
|
||||||
import { useCreateRoom } from '@/features/rooms'
|
import { useCreateRoom } from '@/features/rooms'
|
||||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
|
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { useState } from 'react'
|
||||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
|
||||||
import { ReactNode, useState } from 'react'
|
|
||||||
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
alignItems: 'center',
|
|
||||||
margin: 'auto',
|
|
||||||
display: 'inline-flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: '100%',
|
|
||||||
justifyContent: 'normal',
|
|
||||||
padding: '0 1rem',
|
|
||||||
width: 'calc(100% - 2rem)',
|
|
||||||
opacity: 0,
|
|
||||||
animation: '.5s ease-in fade 0s forwards',
|
|
||||||
lg: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
display: 'inline-flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexBasis: 'auto',
|
|
||||||
flexShrink: 0,
|
|
||||||
maxWidth: '38rem',
|
|
||||||
width: '100%',
|
|
||||||
padding: '1rem 3%',
|
|
||||||
marginTop: 'auto',
|
|
||||||
lg: {
|
|
||||||
margin: 0,
|
|
||||||
textAlign: 'left',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
flexShrink: '1',
|
|
||||||
flexBasis: '36rem',
|
|
||||||
maxWidth: '36rem',
|
|
||||||
padding: '1em 3em',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RightColumn = ({ children }: { children?: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
padding: '1rem 3%',
|
|
||||||
marginBottom: 'auto',
|
|
||||||
flexBasis: 'auto',
|
|
||||||
flexShrink: 0,
|
|
||||||
maxWidth: '39rem',
|
|
||||||
lg: {
|
|
||||||
margin: 0,
|
|
||||||
flexBasis: '45%',
|
|
||||||
padding: '1em 3em',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Separator = styled('div', {
|
|
||||||
base: {
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: '#747775',
|
|
||||||
marginTop: '2.5rem',
|
|
||||||
maxWidth: '30rem',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Heading = styled('h1', {
|
|
||||||
base: {
|
|
||||||
fontWeight: '500',
|
|
||||||
fontStyle: 'normal',
|
|
||||||
fontStretch: 'normal',
|
|
||||||
fontOpticalSizing: 'auto',
|
|
||||||
marginBottom: 0,
|
|
||||||
paddingBottom: '0.75rem',
|
|
||||||
fontSize: '2.3rem',
|
|
||||||
lineHeight: '2.5rem',
|
|
||||||
letterSpacing: '0',
|
|
||||||
xsm: {
|
|
||||||
fontSize: '3rem',
|
|
||||||
lineHeight: '3.2rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const IntroText = styled('div', {
|
|
||||||
base: {
|
|
||||||
marginBottom: '3rem',
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
lineHeight: '1.8rem',
|
|
||||||
textWrap: 'pretty',
|
|
||||||
maxWidth: '32rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
@@ -154,90 +30,64 @@ export const Home = () => {
|
|||||||
return (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
<Screen>
|
<Screen>
|
||||||
<Columns>
|
<Centered width="fit-content">
|
||||||
<LeftColumn>
|
<Text as="h1" variant="display">
|
||||||
<Heading>{t('heading')}</Heading>
|
{t('heading')}
|
||||||
<IntroText>{t('intro')}</IntroText>
|
</Text>
|
||||||
<div
|
<Text as="p" variant="h3">
|
||||||
className={css({
|
{t('intro')}
|
||||||
display: 'flex',
|
</Text>
|
||||||
gap: 0.5,
|
{!isLoggedIn && (
|
||||||
flexDirection: { base: 'column', xsm: 'row' },
|
<Text margin="sm" variant="note">
|
||||||
alignItems: { base: 'center', xsm: 'items-start' },
|
{t('loginToCreateMeeting')}
|
||||||
})}
|
</Text>
|
||||||
>
|
)}
|
||||||
{isLoggedIn ? (
|
<HStack gap="gutter">
|
||||||
<Menu>
|
{isLoggedIn ? (
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
<Menu>
|
||||||
{t('createMeeting')}
|
<Button variant="primary">{t('createMeeting')}</Button>
|
||||||
</Button>
|
<RACMenu>
|
||||||
<RACMenu>
|
<MenuItem
|
||||||
<MenuItem
|
className={menuItemRecipe({ icon: true })}
|
||||||
className={menuItemRecipe({ icon: true })}
|
onAction={async () => {
|
||||||
onAction={async () => {
|
const slug = generateRoomId()
|
||||||
const slug = generateRoomId()
|
createRoom({ slug, username }).then((data) =>
|
||||||
createRoom({ slug, username }).then((data) =>
|
navigateTo('room', data.slug, {
|
||||||
navigateTo('room', data.slug, {
|
state: { create: true, initialRoomData: data },
|
||||||
state: { create: true, initialRoomData: data },
|
})
|
||||||
})
|
)
|
||||||
)
|
}}
|
||||||
}}
|
>
|
||||||
data-attr="create-option-instant"
|
<RiAddLine size={18} />
|
||||||
>
|
{t('createMenu.instantOption')}
|
||||||
<RiAddLine size={18} />
|
</MenuItem>
|
||||||
{t('createMenu.instantOption')}
|
<MenuItem
|
||||||
</MenuItem>
|
className={menuItemRecipe({ icon: true })}
|
||||||
<MenuItem
|
onAction={() => {
|
||||||
className={menuItemRecipe({ icon: true })}
|
const slug = generateRoomId()
|
||||||
onAction={() => {
|
createRoom({ slug, username }).then((data) =>
|
||||||
const slug = generateRoomId()
|
setLaterRoomId(data.slug)
|
||||||
createRoom({ slug, username }).then((data) =>
|
)
|
||||||
setLaterRoomId(data.slug)
|
}}
|
||||||
)
|
>
|
||||||
}}
|
<RiLink size={18} />
|
||||||
data-attr="create-option-later"
|
{t('createMenu.laterOption')}
|
||||||
>
|
</MenuItem>
|
||||||
<RiLink size={18} />
|
</RACMenu>
|
||||||
{t('createMenu.laterOption')}
|
</Menu>
|
||||||
</MenuItem>
|
) : (
|
||||||
</RACMenu>
|
<Button variant="primary" href={authUrl()}>
|
||||||
</Menu>
|
{t('login', { ns: 'global' })}
|
||||||
) : (
|
</Button>
|
||||||
<ProConnectButton hint={false} />
|
)}
|
||||||
)}
|
<DialogTrigger>
|
||||||
<DialogTrigger>
|
<Button variant="primary" outline>
|
||||||
<Button
|
{t('joinMeeting')}
|
||||||
variant="primary"
|
</Button>
|
||||||
outline
|
<JoinMeetingDialog />
|
||||||
style={{
|
</DialogTrigger>
|
||||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
</HStack>
|
||||||
}}
|
</Centered>
|
||||||
>
|
|
||||||
{t('joinMeeting')}
|
|
||||||
</Button>
|
|
||||||
<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
|
<LaterMeetingDialog
|
||||||
roomId={laterRoomId || ''}
|
roomId={laterRoomId || ''}
|
||||||
onOpenChange={() => setLaterRoomId(null)}
|
onOpenChange={() => setLaterRoomId(null)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
import { NotificationType } from './NotificationType'
|
import { NotificationType } from './NotificationType'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
@@ -34,48 +34,47 @@ export const MainNotificationToast = () => {
|
|||||||
}, [room, triggerNotificationSound])
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeParticipantNotifications = (participant: Participant) => {
|
const removeJoinNotification = (participant: Participant) => {
|
||||||
toastQueue.visibleToasts.forEach((toast) => {
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
if (toast.content.participant === participant) {
|
(toast) =>
|
||||||
toastQueue.close(toast.key)
|
toast.content.participant === participant &&
|
||||||
}
|
toast.content.type === NotificationType.Joined
|
||||||
})
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
|
|
||||||
return () => {
|
|
||||||
room.off(
|
|
||||||
RoomEvent.ParticipantDisconnected,
|
|
||||||
removeParticipantNotifications
|
|
||||||
)
|
)
|
||||||
|
if (existingToast) {
|
||||||
|
toastQueue.close(existingToast.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
|
||||||
}
|
}
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
|
// fixme - close all related toasters when hands are lowered remotely
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
const handleNotificationReceived = (
|
const handleNotificationReceived = (
|
||||||
prevMetadataStr: string | undefined,
|
payload: Uint8Array,
|
||||||
participant: Participant
|
participant?: RemoteParticipant
|
||||||
) => {
|
) => {
|
||||||
if (!participant) return
|
if (!participant) {
|
||||||
if (isMobileBrowser()) return
|
return
|
||||||
if (participant.isLocal) return
|
}
|
||||||
|
if (isMobileBrowser()) {
|
||||||
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
|
return
|
||||||
const metadata = JSON.parse(participant.metadata || '{}')
|
}
|
||||||
|
const notification = decoder.decode(payload)
|
||||||
if (prevMetadata.raised == metadata.raised) return
|
|
||||||
|
|
||||||
const existingToast = toastQueue.visibleToasts.find(
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
(toast) =>
|
(toast) =>
|
||||||
toast.content.participant === participant &&
|
toast.content.participant === participant &&
|
||||||
toast.content.type === NotificationType.Raised
|
toast.content.type === NotificationType.Raised
|
||||||
)
|
)
|
||||||
|
if (existingToast && notification === NotificationType.Lowered) {
|
||||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
|
||||||
toastQueue.close(existingToast.key)
|
toastQueue.close(existingToast.key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!existingToast && notification === NotificationType.Raised) {
|
||||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
|
||||||
triggerNotificationSound(NotificationType.Raised)
|
triggerNotificationSound(NotificationType.Raised)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
@@ -87,25 +86,15 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
room.on(RoomEvent.DataReceived, handleNotificationReceived)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
room.off(RoomEvent.DataReceived, handleNotificationReceived)
|
||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound])
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const closeAllToasts = () => {
|
|
||||||
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.Disconnected, closeAllToasts)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.Disconnected, closeAllToasts)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { Button, Div } from '@/primitives'
|
import { Button, Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiCloseLine, RiHand } from '@remixicon/react'
|
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
|
||||||
|
|
||||||
export function ToastRaised({ state, ...props }: ToastProps) {
|
export function ToastRaised({ state, ...props }: ToastProps) {
|
||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
@@ -17,7 +17,7 @@ export function ToastRaised({ state, ...props }: ToastProps) {
|
|||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
|||||||
@@ -12,19 +12,6 @@ const StyledContainer = styled('div', {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '2px',
|
gap: '2px',
|
||||||
},
|
},
|
||||||
variants: {
|
|
||||||
pushToTalk: {
|
|
||||||
true: {
|
|
||||||
height: '46px',
|
|
||||||
width: '58px',
|
|
||||||
borderLeftRadius: 8,
|
|
||||||
borderRightRadius: 0,
|
|
||||||
backgroundColor: '#dbeafe',
|
|
||||||
border: '1px solid #3b82f6',
|
|
||||||
gap: '3px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledChild = styled('div', {
|
const StyledChild = styled('div', {
|
||||||
@@ -49,32 +36,16 @@ const StyledChild = styled('div', {
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
small: {
|
small: {
|
||||||
animationName: 'active_speaker_small',
|
animationName: 'active_speake_small',
|
||||||
},
|
|
||||||
},
|
|
||||||
pushToTalk: {
|
|
||||||
true: {
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ActiveSpeakerProps = {
|
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
|
||||||
isSpeaking: boolean
|
|
||||||
pushToTalk?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActiveSpeaker = ({
|
|
||||||
isSpeaking,
|
|
||||||
pushToTalk,
|
|
||||||
}: ActiveSpeakerProps) => {
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
<StyledContainer>
|
||||||
<StyledChild
|
<StyledChild
|
||||||
pushToTalk={pushToTalk}
|
|
||||||
active={isSpeaking}
|
active={isSpeaking}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
@@ -82,14 +53,12 @@ export const ActiveSpeaker = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StyledChild
|
<StyledChild
|
||||||
pushToTalk={pushToTalk}
|
|
||||||
active={isSpeaking}
|
active={isSpeaking}
|
||||||
style={{
|
style={{
|
||||||
animationDelay: '100ms',
|
animationDelay: '100ms',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StyledChild
|
<StyledChild
|
||||||
pushToTalk={pushToTalk}
|
|
||||||
active={isSpeaking}
|
active={isSpeaking}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
import {
|
||||||
|
formatChatMessageLinks,
|
||||||
|
LiveKitRoom,
|
||||||
|
type LocalUserChoices,
|
||||||
|
} from '@livekit/components-react'
|
||||||
import { Room, RoomOptions } from 'livekit-client'
|
import { Room, RoomOptions } from 'livekit-client'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { QueryAware } from '@/components/QueryAware'
|
import { QueryAware } from '@/components/QueryAware'
|
||||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||||
@@ -14,7 +19,6 @@ import { useCreateRoom } from '../api/createRoom'
|
|||||||
import { InviteDialog } from './InviteDialog'
|
import { InviteDialog } from './InviteDialog'
|
||||||
|
|
||||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -27,9 +31,6 @@ export const Conference = ({
|
|||||||
mode?: 'join' | 'create'
|
mode?: 'join' | 'create'
|
||||||
initialRoomData?: ApiRoom
|
initialRoomData?: ApiRoom
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
|
||||||
}, [roomId])
|
|
||||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -78,6 +79,25 @@ export const Conference = ({
|
|||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks for actual click on the leave button instead of
|
||||||
|
* relying on LiveKitRoom onDisconnected because onDisconnected
|
||||||
|
* triggers even on page reload, it's not a user "onLeave" event really.
|
||||||
|
* Here we want to react to the user actually deciding to leave.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const checkOnLeaveClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.classList.contains('lk-disconnect-button')) {
|
||||||
|
navigateTo('feedback')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.addEventListener('click', checkOnLeaveClick)
|
||||||
|
return () => {
|
||||||
|
document.body.removeEventListener('click', checkOnLeaveClick)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||||
@@ -89,13 +109,6 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
|
||||||
// These settings help ensure successful connections in poor network conditions
|
|
||||||
const connectOptions = {
|
|
||||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
|
||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false}>
|
<Screen header={false}>
|
||||||
@@ -106,9 +119,8 @@ export const Conference = ({
|
|||||||
connect={true}
|
connect={true}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
connectOptions={connectOptions}
|
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
<InviteDialog
|
<InviteDialog
|
||||||
isOpen={showInviteDialog}
|
isOpen={showInviteDialog}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const InviteDialog = ({
|
|||||||
setIsCopied(true)
|
setIsCopied(true)
|
||||||
}}
|
}}
|
||||||
onHoverChange={setIsHovered}
|
onHoverChange={setIsHovered}
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { CenteredContent } from '@/layout/CenteredContent'
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
import { useUser } from '@/features/auth'
|
|
||||||
|
|
||||||
export const Join = ({
|
export const Join = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -10,7 +9,6 @@ export const Join = ({
|
|||||||
onSubmit: (choices: LocalUserChoices) => void
|
onSubmit: (choices: LocalUserChoices) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const { user } = useUser()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered">
|
<Screen layout="centered">
|
||||||
@@ -18,11 +16,10 @@ export const Join = ({
|
|||||||
<PreJoin
|
<PreJoin
|
||||||
persistUserChoices
|
persistUserChoices
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
micLabel={t('join.audioinput.label')}
|
micLabel={t('join.micLabel')}
|
||||||
camLabel={t('join.videoinput.label')}
|
camLabel={t('join.camlabel')}
|
||||||
joinLabel={t('join.joinLabel')}
|
joinLabel={t('join.joinLabel')}
|
||||||
userLabel={t('join.usernameLabel')}
|
userLabel={t('join.userLabel')}
|
||||||
defaults={{ username: user?.full_name }}
|
|
||||||
/>
|
/>
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
import { Button, H, Text, TextArea } from '@/primitives'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { cva } from '@/styled-system/css'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { styled, VStack } from '@/styled-system/jsx'
|
|
||||||
import { usePostHog } from 'posthog-js/react'
|
|
||||||
import { PostHog } from 'posthog-js'
|
|
||||||
|
|
||||||
const Card = styled('div', {
|
|
||||||
base: {
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'gray.300',
|
|
||||||
padding: '1rem',
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
boxShadow: '',
|
|
||||||
minWidth: '380px',
|
|
||||||
minHeight: '196px',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Bar = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
border: '2px solid',
|
|
||||||
borderColor: 'gray.300',
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
scrollbar: 'hidden',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ratingButtonRecipe = cva({
|
|
||||||
base: {
|
|
||||||
backgroundColor: 'white',
|
|
||||||
color: 'initial',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 0,
|
|
||||||
padding: '0.5rem 0.85rem',
|
|
||||||
flexGrow: '1',
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
selected: {
|
|
||||||
true: {
|
|
||||||
backgroundColor: '#1d4ed8',
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'gray.100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderLeft: {
|
|
||||||
true: {
|
|
||||||
borderLeft: '1px solid',
|
|
||||||
borderColor: 'gray.300',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelRecipe = cva({
|
|
||||||
base: {
|
|
||||||
color: 'gray.600',
|
|
||||||
paddingTop: '0.25rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const OpenFeedback = ({
|
|
||||||
posthog,
|
|
||||||
onNext,
|
|
||||||
}: {
|
|
||||||
posthog: PostHog
|
|
||||||
onNext: () => void
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
|
|
||||||
const [feedback, setFeedback] = useState('')
|
|
||||||
|
|
||||||
const onContinue = () => {
|
|
||||||
setFeedback('')
|
|
||||||
onNext()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
try {
|
|
||||||
posthog.capture('survey sent', {
|
|
||||||
$survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
|
|
||||||
$survey_response: feedback,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e)
|
|
||||||
} finally {
|
|
||||||
onContinue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<H lvl={3}>{t('question')}</H>
|
|
||||||
<TextArea
|
|
||||||
id="feedbackInput"
|
|
||||||
name="feedback"
|
|
||||||
placeholder={t('placeholder')}
|
|
||||||
required
|
|
||||||
value={feedback}
|
|
||||||
onChange={(e) => setFeedback(e.target.value)}
|
|
||||||
style={{
|
|
||||||
minHeight: '150px',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<VStack gap="0.5">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
isDisabled={!feedback}
|
|
||||||
onPress={onSubmit}
|
|
||||||
>
|
|
||||||
{t('submit')}
|
|
||||||
</Button>
|
|
||||||
<Button invisible size="sm" fullWidth onPress={onNext}>
|
|
||||||
{t('skip')}
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RateQuality = ({
|
|
||||||
posthog,
|
|
||||||
onNext,
|
|
||||||
maxRating = 7,
|
|
||||||
}: {
|
|
||||||
posthog: PostHog
|
|
||||||
onNext: () => void
|
|
||||||
maxRating?: number
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
|
|
||||||
const [selectedRating, setSelectedRating] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const handleRatingClick = (rating: number) => {
|
|
||||||
setSelectedRating((prevRating) => (prevRating === rating ? null : rating))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
try {
|
|
||||||
posthog.capture('survey sent', {
|
|
||||||
$survey_id: '01933c22-d005-0000-b623-20b752171e2e',
|
|
||||||
$survey_response: `${selectedRating}`,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e)
|
|
||||||
} finally {
|
|
||||||
setSelectedRating(null)
|
|
||||||
onNext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<H lvl={3}>{t('question')}</H>
|
|
||||||
<Bar>
|
|
||||||
{[...Array(maxRating)].map((_, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
onPress={() => handleRatingClick(index + 1)}
|
|
||||||
className={ratingButtonRecipe({
|
|
||||||
selected: selectedRating === index + 1,
|
|
||||||
borderLeft: index != 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="sm" className={labelRecipe()}>
|
|
||||||
{t('levels.min')}
|
|
||||||
</Text>
|
|
||||||
<Text variant="sm" className={labelRecipe()}>
|
|
||||||
{t('levels.max')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
isDisabled={!selectedRating}
|
|
||||||
onPress={onSubmit}
|
|
||||||
>
|
|
||||||
{t('submit')}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'confirmationMessage' })
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onNext()
|
|
||||||
}, 10000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [onNext])
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
maxWidth: '380px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<H lvl={3}>{t('heading')}</H>
|
|
||||||
<Text as="p" variant="paragraph" centered>
|
|
||||||
{t('body')}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Rating = () => {
|
|
||||||
const posthog = usePostHog()
|
|
||||||
|
|
||||||
const [step, setStep] = useState(0)
|
|
||||||
|
|
||||||
if (step == 0) {
|
|
||||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step == 1) {
|
|
||||||
return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step == 2) {
|
|
||||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while lowering hands :', error)
|
|
||||||
throw new Error('An error occurred while lowering hands.')
|
throw new Error('An error occurred while lowering hands.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { useParams } from 'wouter'
|
||||||
|
|
||||||
|
export const useRecordRoom = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
const { roomId: roomSlug } = useParams()
|
||||||
|
|
||||||
|
const recordRoom = () => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
if (!roomSlug) {
|
||||||
|
throw new Error('Room ID is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'/twirp/livekit.Egress/StartRoomCompositeEgress'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_name: data.livekit.room,
|
||||||
|
audio_only: true,
|
||||||
|
file_outputs: [
|
||||||
|
{
|
||||||
|
file_extension: 'ogg',
|
||||||
|
filepath: `{room_name}_{time}_${roomSlug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecordingRoom = (egressId: string) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
egressId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recordRoom, stopRecordingRoom }
|
||||||
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { LocalVideoTrack } from 'livekit-client'
|
|
||||||
import { Text, P, ToggleButton, Div, H } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
|
||||||
import {
|
|
||||||
BackgroundBlur,
|
|
||||||
BackgroundOptions,
|
|
||||||
ProcessorWrapper,
|
|
||||||
BackgroundTransformer,
|
|
||||||
} from '@livekit/track-processors'
|
|
||||||
|
|
||||||
const Information = styled('div', {
|
|
||||||
base: {
|
|
||||||
backgroundColor: 'orange.50',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '0.75rem 0.75rem',
|
|
||||||
marginTop: '0.8rem',
|
|
||||||
alignItems: 'start',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
enum BlurRadius {
|
|
||||||
NONE = 0,
|
|
||||||
LIGHT = 5,
|
|
||||||
NORMAL = 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Effects = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
|
||||||
const { isCameraEnabled, cameraTrack, localParticipant } =
|
|
||||||
useLocalParticipant()
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
const [processorPending, setProcessorPending] = useState(false)
|
|
||||||
|
|
||||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
|
||||||
|
|
||||||
const getProcessor = () => {
|
|
||||||
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getBlurRadius = (): BlurRadius => {
|
|
||||||
const processor = getProcessor()
|
|
||||||
return (
|
|
||||||
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
|
|
||||||
BlurRadius.NONE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleBlur = async (blurRadius: number) => {
|
|
||||||
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
|
|
||||||
if (!localCameraTrack) return
|
|
||||||
|
|
||||||
setProcessorPending(true)
|
|
||||||
|
|
||||||
const processor = getProcessor()
|
|
||||||
const currentBlurRadius = getBlurRadius()
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (blurRadius == currentBlurRadius && processor) {
|
|
||||||
await localCameraTrack.stopProcessor()
|
|
||||||
} else if (!processor) {
|
|
||||||
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
|
|
||||||
} else {
|
|
||||||
await processor?.updateTransformerOptions({ blurRadius })
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error applying blur:', error)
|
|
||||||
} finally {
|
|
||||||
setProcessorPending(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const videoElement = videoRef.current
|
|
||||||
if (!videoElement) return
|
|
||||||
|
|
||||||
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
|
|
||||||
attachVideoTrack()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!videoElement) return
|
|
||||||
localCameraTrack.detach(videoElement)
|
|
||||||
}
|
|
||||||
}, [localCameraTrack, isCameraEnabled])
|
|
||||||
|
|
||||||
const isSelected = (blurRadius: BlurRadius) => {
|
|
||||||
return isCameraEnabled && getBlurRadius() == blurRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipLabel = (blurRadius: BlurRadius) => {
|
|
||||||
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack padding="0 1.5rem" overflowY="scroll">
|
|
||||||
{localCameraTrack && isCameraEnabled ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
width="100%"
|
|
||||||
muted
|
|
||||||
style={{
|
|
||||||
transform: 'rotateY(180deg)',
|
|
||||||
minHeight: '175px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '174px',
|
|
||||||
display: 'flex',
|
|
||||||
backgroundColor: 'black',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<P
|
|
||||||
style={{
|
|
||||||
color: 'white',
|
|
||||||
textAlign: 'center',
|
|
||||||
textWrap: 'balance',
|
|
||||||
marginBottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('activateCamera')}
|
|
||||||
</P>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Div
|
|
||||||
alignItems={'left'}
|
|
||||||
width={'100%'}
|
|
||||||
style={{
|
|
||||||
border: '1px solid #dadce0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 .625rem',
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
style={{
|
|
||||||
marginBottom: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('heading')}
|
|
||||||
</H>
|
|
||||||
{ProcessorWrapper.isSupported ? (
|
|
||||||
<HStack>
|
|
||||||
<ToggleButton
|
|
||||||
size={'sm'}
|
|
||||||
legacyStyle
|
|
||||||
aria-label={tooltipLabel(BlurRadius.LIGHT)}
|
|
||||||
tooltip={tooltipLabel(BlurRadius.LIGHT)}
|
|
||||||
isDisabled={processorPending}
|
|
||||||
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
|
|
||||||
isSelected={isSelected(BlurRadius.LIGHT)}
|
|
||||||
>
|
|
||||||
{t('blur.light')}
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
size={'sm'}
|
|
||||||
legacyStyle
|
|
||||||
aria-label={tooltipLabel(BlurRadius.NORMAL)}
|
|
||||||
tooltip={tooltipLabel(BlurRadius.NORMAL)}
|
|
||||||
isDisabled={processorPending}
|
|
||||||
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
|
|
||||||
isSelected={isSelected(BlurRadius.NORMAL)}
|
|
||||||
>
|
|
||||||
{t('blur.normal')}
|
|
||||||
</ToggleButton>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
<Text variant="sm">{t('notAvailable')}</Text>
|
|
||||||
)}
|
|
||||||
<Information>
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
style={{
|
|
||||||
textWrap: 'balance',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⚠︎ {t('experimental')}
|
|
||||||
</Text>
|
|
||||||
</Information>
|
|
||||||
</Div>
|
|
||||||
</VStack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
|
export const RecordingIndicator = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [isRecording, setIsRecording] = useState(room.isRecording)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRecordingStatusChanges = (isRecording: boolean) => {
|
||||||
|
setIsRecording(isRecording)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Room is recording: {isRecording ? 'yes' : 'no'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Heading } from 'react-aria-components'
|
|
||||||
import { text } from '@/primitives/Text'
|
|
||||||
import { Box, Button, Div } from '@/primitives'
|
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
|
||||||
import { ReactNode } from 'react'
|
|
||||||
import { Effects } from './Effects'
|
|
||||||
import { Chat } from '../prefabs/Chat'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
|
||||||
title: string
|
|
||||||
children: ReactNode
|
|
||||||
onClose: () => void
|
|
||||||
isClosed: boolean
|
|
||||||
closeButtonTooltip: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
onClose,
|
|
||||||
isClosed,
|
|
||||||
closeButtonTooltip,
|
|
||||||
}: StyledSidePanelProps) => (
|
|
||||||
<Box
|
|
||||||
size="sm"
|
|
||||||
className={css({
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
|
||||||
padding: 0,
|
|
||||||
gap: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: '80px',
|
|
||||||
width: '360px',
|
|
||||||
position: 'absolute',
|
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
|
||||||
})}
|
|
||||||
style={{
|
|
||||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Heading
|
|
||||||
slot="title"
|
|
||||||
level={1}
|
|
||||||
className={text({ variant: 'h2' })}
|
|
||||||
style={{
|
|
||||||
paddingLeft: '1.5rem',
|
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Heading>
|
|
||||||
<Div
|
|
||||||
position="absolute"
|
|
||||||
top="5"
|
|
||||||
right="5"
|
|
||||||
style={{
|
|
||||||
display: isClosed ? 'none' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
invisible
|
|
||||||
size="xs"
|
|
||||||
onPress={onClose}
|
|
||||||
aria-label={closeButtonTooltip}
|
|
||||||
tooltip={closeButtonTooltip}
|
|
||||||
>
|
|
||||||
<RiCloseLine />
|
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
type PanelProps = {
|
|
||||||
isOpen: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: isOpen ? 'inherit' : 'none',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SidePanel = () => {
|
|
||||||
const {
|
|
||||||
activePanelId,
|
|
||||||
isParticipantsOpen,
|
|
||||||
isEffectsOpen,
|
|
||||||
isChatOpen,
|
|
||||||
isSidePanelOpen,
|
|
||||||
} = useSidePanel()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledSidePanel
|
|
||||||
title={t(`heading.${activePanelId}`)}
|
|
||||||
onClose={() => (layoutStore.activePanelId = null)}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
|
||||||
content: t(`content.${activePanelId}`),
|
|
||||||
})}
|
|
||||||
isClosed={!isSidePanelOpen}
|
|
||||||
>
|
|
||||||
<Panel isOpen={isParticipantsOpen}>
|
|
||||||
<ParticipantsList />
|
|
||||||
</Panel>
|
|
||||||
<Panel isOpen={isEffectsOpen}>
|
|
||||||
<Effects />
|
|
||||||
</Panel>
|
|
||||||
<Panel isOpen={isChatOpen}>
|
|
||||||
<Chat />
|
|
||||||
</Panel>
|
|
||||||
</StyledSidePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { ReceivedChatMessage } from '@livekit/components-core'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { MessageFormatter } from '@livekit/components-react'
|
|
||||||
|
|
||||||
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
|
||||||
entry: ReceivedChatMessage
|
|
||||||
hideMetadata?: boolean
|
|
||||||
messageFormatter?: MessageFormatter
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatEntry: (
|
|
||||||
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
ChatEntryProps
|
|
||||||
>(function ChatEntry(
|
|
||||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
// Fixme - Livekit messageFormatter strips '\n' char
|
|
||||||
const formattedMessage = React.useMemo(() => {
|
|
||||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
|
||||||
}, [entry.message, messageFormatter])
|
|
||||||
const time = new Date(entry.timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
})}
|
|
||||||
ref={ref}
|
|
||||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
|
||||||
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{!hideMetadata && (
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Text bold={true} variant="sm">
|
|
||||||
{entry.from?.name ?? entry.from?.identity}
|
|
||||||
</Text>
|
|
||||||
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
|
||||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
|
||||||
</Text>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
'& .lk-chat-link': {
|
|
||||||
color: 'blue',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{formattedMessage}
|
|
||||||
</Text>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Button, TextArea } from '@/primitives'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { RefObject } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const MAX_ROWS = 6
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
inputRef: RefObject<HTMLTextAreaElement>
|
|
||||||
onSubmit: (text: string) => void
|
|
||||||
isSending: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatInput = ({
|
|
||||||
inputRef,
|
|
||||||
onSubmit,
|
|
||||||
isSending,
|
|
||||||
}: ChatInputProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [rows, setRows] = useState(1)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
onSubmit(text)
|
|
||||||
setText('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDisabled = !text.trim() || isSending
|
|
||||||
|
|
||||||
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
|
||||||
e.preventDefault()
|
|
||||||
if (!isDisabled) handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resize = () => {
|
|
||||||
if (!inputRef.current) return
|
|
||||||
|
|
||||||
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
|
||||||
const previousRows = inputRef.current.rows
|
|
||||||
inputRef.current.rows = 1
|
|
||||||
|
|
||||||
const currentRows = Math.floor(
|
|
||||||
inputRef.current.scrollHeight / textAreaLineHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentRows === previousRows) {
|
|
||||||
inputRef.current.rows = currentRows
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRows >= MAX_ROWS) {
|
|
||||||
inputRef.current.rows = MAX_ROWS
|
|
||||||
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRows < MAX_ROWS) {
|
|
||||||
inputRef.current.style.overflowY = 'hidden'
|
|
||||||
} else {
|
|
||||||
inputRef.current.style.overflowY = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
|
||||||
}
|
|
||||||
|
|
||||||
resize()
|
|
||||||
}, [text, inputRef])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
style={{
|
|
||||||
margin: '0.75rem 0 1.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
backgroundColor: '#f3f4f6',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
submitOnEnter(e)
|
|
||||||
}}
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setText(e.target.value)
|
|
||||||
}}
|
|
||||||
rows={rows || 1}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
height: 'auto',
|
|
||||||
minHeight: `34px`,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
padding: '7px 10px',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
}}
|
|
||||||
placeholderStyle={'strong'}
|
|
||||||
spellCheck={false}
|
|
||||||
maxLength={500}
|
|
||||||
aria-label={t('textArea.label')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
size="sm"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t('button.label')}
|
|
||||||
>
|
|
||||||
<RiSendPlane2Fill />
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiChat1Line } from '@remixicon/react'
|
import { RiChat1Line } from '@remixicon/react'
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { chatStore } from '@/stores/chat'
|
import { css } from '@/styled-system/css'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||||
|
|
||||||
export const ChatToggle = () => {
|
export const ChatToggle = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||||
|
|
||||||
const { isChatOpen, toggleChat } = useSidePanel()
|
|
||||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,15 +20,14 @@ export const ChatToggle = () => {
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
square
|
square
|
||||||
legacyStyle
|
legacyStyle
|
||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(`controls.chat.${tooltipLabel}`)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(`controls.chat.${tooltipLabel}`)}
|
||||||
isSelected={isChatOpen}
|
isSelected={isChatOpen}
|
||||||
onPress={() => toggleChat()}
|
onPress={() => toggleChat()}
|
||||||
data-attr={`controls-chat-${tooltipLabel}`}
|
|
||||||
>
|
>
|
||||||
<RiChat1Line />
|
<RiChat1Line />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
{!!chatSnap.unreadMessages && (
|
{!!unreadMessages && (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -4,16 +4,30 @@ import { ToggleButton } from '@/primitives'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
export const HandToggle = () => {
|
export const HandToggle = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||||
participant: room.localParticipant,
|
participant: room.localParticipant,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
const label = isHandRaised
|
||||||
|
? t('controls.hand.lower')
|
||||||
|
: t('controls.hand.raise')
|
||||||
|
|
||||||
|
const notifyOtherParticipants = (isHandRaised: boolean) => {
|
||||||
|
room.localParticipant.publishData(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
|
||||||
|
),
|
||||||
|
{
|
||||||
|
reliable: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -25,11 +39,13 @@ export const HandToggle = () => {
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
square
|
square
|
||||||
legacyStyle
|
legacyStyle
|
||||||
aria-label={t(tooltipLabel)}
|
aria-label={label}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={label}
|
||||||
isSelected={isHandRaised}
|
isSelected={isHandRaised}
|
||||||
onPress={() => toggleRaisedHand()}
|
onPress={() => {
|
||||||
data-attr={`controls-hand-${tooltipLabel}`}
|
notifyOtherParticipants(isHandRaised)
|
||||||
|
toggleRaisedHand()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiHand />
|
<RiHand />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user