mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 808fc7a00f | |||
| c8b4592d88 | |||
| db071f0dcc | |||
| b1b28fe330 | |||
| 98950f43af | |||
| b43f9922b6 | |||
| d49dc72849 | |||
| 82f0251101 | |||
| 2f675c2a3f | |||
| 7105c7cbae | |||
| 376ff8982c | |||
| a4820ae867 | |||
| 2a56cda55c | |||
| 817cf25b37 | |||
| fdaf567f5d | |||
| 0cf5ab1825 | |||
| c9c5d3b452 | |||
| f0b250739c | |||
| 41c9693d10 | |||
| 83cfcacc0e | |||
| e8618099ac | |||
| ac183c9eb9 | |||
| 3dcc93b630 | |||
| 3614b1e803 | |||
| 5e57647b34 | |||
| e271c87a20 | |||
| 40d1f01277 | |||
| 8477296471 | |||
| d200eeb6bd | |||
| 7a51b09664 |
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.12.6-alpine3.20 AS base
|
FROM python:3.12.6-alpine3.20 as base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
|||||||
apk upgrade
|
apk upgrade
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base as back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:20 as mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ RUN yarn install --frozen-lockfile && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
FROM base AS link-collector
|
FROM base as link-collector
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
@@ -58,7 +58,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
|||||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
||||||
|
|
||||||
# ---- Core application image ----
|
# ---- Core application image ----
|
||||||
FROM base AS core
|
FROM base as core
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ WORKDIR /app
|
|||||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||||
|
|
||||||
# ---- Development image ----
|
# ---- Development image ----
|
||||||
FROM core AS backend-development
|
FROM core as backend-development
|
||||||
|
|
||||||
# Switch back to the root user to install development dependencies
|
# Switch back to the root user to install development dependencies
|
||||||
USER root:root
|
USER root:root
|
||||||
@@ -119,7 +119,7 @@ ENV DB_HOST=postgresql \
|
|||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core AS backend-production
|
FROM core as backend-production
|
||||||
|
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
|
|||||||
@@ -302,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,3 +1,4 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgresql:
|
postgresql:
|
||||||
+1
-1
Submodule secrets updated: 8ef9f4513a...2ef2610071
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
|
||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from minio import Minio
|
||||||
|
from django.conf import settings
|
||||||
|
import openai
|
||||||
|
import logging
|
||||||
|
from ..models import Room, RoleChoices
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(transcript, date):
|
||||||
|
return f"""
|
||||||
|
|
||||||
|
Audience: Coworkers.
|
||||||
|
|
||||||
|
**Do:**
|
||||||
|
- Detect the language of the transcript and provide your entire response in the same language.
|
||||||
|
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
|
||||||
|
- Ensure the accuracy of all information and refrain from adding unverified details.
|
||||||
|
- Format the response using proper markdown and structured sections.
|
||||||
|
- Be concise and avoid repeating yourself between the sections.
|
||||||
|
- Be super precise on nickname
|
||||||
|
- Be a nit-picker
|
||||||
|
- Auto-evaluate your response
|
||||||
|
|
||||||
|
**Don't:**
|
||||||
|
- Write something your are not sure.
|
||||||
|
- Write something that is not mention in the transcript.
|
||||||
|
- Don't make mistake while mentioning someone
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
|
||||||
|
|
||||||
|
1. **Summary**: Write a TL;DR of the meeting.
|
||||||
|
2. **Subjects Discussed**: List the key points or issues in bullet points.
|
||||||
|
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
|
||||||
|
|
||||||
|
**Transcript**:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
### Summary [Translate this title based on the transcript’s language]
|
||||||
|
[Provide a brief overview of the key points discussed]
|
||||||
|
|
||||||
|
### Subjects Discussed [Translate this title based on the transcript’s language]
|
||||||
|
- [Summarize each topic concisely]
|
||||||
|
|
||||||
|
### Next Steps [Translate this title based on the transcript’s language]
|
||||||
|
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_room_and_owners(slug):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(slug=slug)
|
||||||
|
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
|
||||||
|
owners = [access.user for access in owner_accesses]
|
||||||
|
|
||||||
|
logger.info("Room %s has owners: %s", slug, owners)
|
||||||
|
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
logger.error("Room with slug %s does not exist", slug)
|
||||||
|
|
||||||
|
owners = None
|
||||||
|
room = None
|
||||||
|
|
||||||
|
return room, owners
|
||||||
|
|
||||||
|
|
||||||
|
def remove_temporary_file(path):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
os.remove(path)
|
||||||
|
logger.info("Temporary file %s has been deleted.", path)
|
||||||
|
|
||||||
|
def get_blocknote_content(summary):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not settings.BLOCKNOTE_CONVERTER_URL:
|
||||||
|
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"markdown": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Converting summary in BlockNote.js…")
|
||||||
|
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'content' in response_data:
|
||||||
|
logger.error(f"Content is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = response_data['content']
|
||||||
|
logger.info("Base64 content: %s", content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_link(content, email):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Create a document for %s", email)
|
||||||
|
|
||||||
|
if not settings.DOCS_BASE_URL:
|
||||||
|
logger.error("DOCS_BASE_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"content": content,
|
||||||
|
"owner": email
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Querying docs…")
|
||||||
|
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'id' in response_data:
|
||||||
|
logger.error(f"ID is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
id = response_data['id']
|
||||||
|
logger.info("Document's id: %s", id)
|
||||||
|
|
||||||
|
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def email_owner_with_summary(room, link, owner):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Emailing owner: %s", owner)
|
||||||
|
|
||||||
|
try:
|
||||||
|
room.email_summary(owners=[owner], link=link)
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
logger.error("Error while emailing owner")
|
||||||
|
|
||||||
|
def strip_room_slug(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[2].split(".")[0]
|
||||||
|
|
||||||
|
def strip_room_date(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[1].split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Minio(
|
||||||
|
settings.MINIO_URL,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def download_temporary_file(minio_client, filename):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
|
||||||
|
logger.info('downloading file %s', filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
|
||||||
|
|
||||||
|
for data in audio_file_stream.stream(32 * 1024):
|
||||||
|
temp_audio_file.write(data)
|
||||||
|
|
||||||
|
temp_file_path = temp_audio_file.name
|
||||||
|
logger.info('Temporary file created at %s', temp_file_path)
|
||||||
|
|
||||||
|
audio_file_stream.close()
|
||||||
|
audio_file_stream.release_conn()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while accessing the object: %s", str(e))
|
||||||
|
|
||||||
|
return temp_file_path
|
||||||
|
|
||||||
|
|
||||||
|
# todo - discuss retry policy if the webhook fail
|
||||||
|
@api_view(["POST"])
|
||||||
|
def minio_webhook(request):
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
|
||||||
|
logger.info('Minio webhook sent %s', data)
|
||||||
|
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record['s3']
|
||||||
|
bucket = s3['bucket']
|
||||||
|
bucket_name = bucket['name']
|
||||||
|
object = s3['object']
|
||||||
|
filename = object['key']
|
||||||
|
|
||||||
|
if bucket_name != settings.MINIO_BUCKET:
|
||||||
|
logger.info('Not interested in this bucket: %s', bucket_name)
|
||||||
|
return Response("Not interested in this bucket")
|
||||||
|
|
||||||
|
if object['contentType'] != 'audio/ogg':
|
||||||
|
logger.info('Not interested in this file type: %s', object['contentType'])
|
||||||
|
return Response("Not interested in this file type")
|
||||||
|
|
||||||
|
room_slug = strip_room_slug(filename)
|
||||||
|
room_date = strip_room_date(filename)
|
||||||
|
logger.info('file received %s for room %s', filename, room_slug)
|
||||||
|
|
||||||
|
minio_client = get_minio_client()
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
summary = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_file_path = download_temporary_file(minio_client, filename)
|
||||||
|
|
||||||
|
if settings.OPENAI_ENABLE and temp_file_path:
|
||||||
|
logger.info('Initiating OpenAI client …')
|
||||||
|
openai_client = openai.OpenAI(
|
||||||
|
api_key=settings.OPENAI_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(temp_file_path, "rb") as audio_file:
|
||||||
|
logger.info('Querying transcription …')
|
||||||
|
transcript = openai_client.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=audio_file
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Transcript: \n %s', transcript)
|
||||||
|
prompt = get_prompt(transcript.text, room_date)
|
||||||
|
|
||||||
|
logger.info('Prompt: \n %s', prompt)
|
||||||
|
|
||||||
|
summary_response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = summary_response.choices[0].message.content
|
||||||
|
logger.info('Summary: \n %s', summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred: %s", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
remove_temporary_file(temp_file_path)
|
||||||
|
|
||||||
|
if not summary:
|
||||||
|
logger.error("Empty summary.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
room, owners = get_room_and_owners(room_slug)
|
||||||
|
|
||||||
|
if not owners or not room:
|
||||||
|
logger.error("No owners in room %s", room_slug)
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
content = get_blocknote_content(summary)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
logger.error("Empty content.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
owner = owners[0]
|
||||||
|
|
||||||
|
link = get_document_link(content, owner.email)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
logger.error("Empty link.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
email_owner_with_summary(room, link, owner)
|
||||||
|
|
||||||
|
return Response("")
|
||||||
@@ -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'),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
+27
-279
@@ -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
|
||||||
@@ -16,6 +15,8 @@ from django.utils.functional import lazy
|
|||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -39,46 +40,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 +102,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 +165,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",
|
||||||
@@ -392,211 +329,22 @@ class Room(Resource):
|
|||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessManager(models.Manager):
|
def email_summary(self, owners, link):
|
||||||
"""Base manager for handling resource access control."""
|
"""Wip"""
|
||||||
|
|
||||||
def filter_user(self, user):
|
template_vars = {
|
||||||
"""Filter accesses for a given user, including both direct and team-based access."""
|
"title": "Votre résumé est prêt",
|
||||||
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
|
"link": link,
|
||||||
|
"room": self.slug,
|
||||||
|
}
|
||||||
|
msg_html = render_to_string("mail/html/summary.html", template_vars)
|
||||||
|
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||||
|
|
||||||
|
for owner in owners:
|
||||||
class BaseAccess(BaseModel):
|
owner.email_user(
|
||||||
"""Base model for accesses to handle resources."""
|
subject="Votre résumé est prêt",
|
||||||
|
from_email=settings.EMAIL_FROM,
|
||||||
user = models.ForeignKey(
|
message=msg_plain,
|
||||||
User,
|
html_message=msg_html,
|
||||||
on_delete=models.CASCADE,
|
fail_silently=False,
|
||||||
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 get_frontend_configuration, viewsets, demo
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -25,6 +24,7 @@ urlpatterns = [
|
|||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path("minio-webhook/", demo.minio_webhook, name="demo"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|
||||||
@@ -327,10 +308,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 +367,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 +376,61 @@ 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(
|
||||||
|
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# todo - totally wip
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
AWS_S3_ENDPOINT_URL = values.Value(
|
||||||
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
AWS_S3_ACCESS_KEY_ID = values.Value(
|
||||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
||||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
AWS_S3_REGION_NAME = values.Value(
|
||||||
{
|
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
||||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
)
|
||||||
"transcript": "core.recording.worker.services.AudioCompositeEgressService",
|
AWS_STORAGE_BUCKET_NAME = values.Value(
|
||||||
},
|
"meet-media-storage",
|
||||||
environ_name="RECORDING_WORKER_CLASSES",
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
|
||||||
"core.recording.event.parsers.MinioParser",
|
OPENAI_API_KEY = values.Value(
|
||||||
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
None, environ_name="OPENAI_API_KEY", environ_prefix=None
|
||||||
environ_prefix=None,
|
|
||||||
)
|
)
|
||||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
OPENAI_ENABLE = values.BooleanValue(
|
||||||
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
True, environ_name="OPENAI_ENABLE", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
|
||||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
# todo - totally wip
|
||||||
|
MINIO_ACCESS_KEY = values.Value(
|
||||||
|
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
MINIO_SECRET_KEY = values.Value(
|
||||||
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_URL = values.Value(
|
||||||
|
None, environ_name="MINIO_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_BUCKET = values.Value(
|
||||||
|
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
BLOCKNOTE_CONVERTER_URL = values.Value(
|
||||||
|
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
|
||||||
|
)
|
||||||
|
DOCS_BASE_URL = values.Value(
|
||||||
|
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -568,6 +552,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"]
|
||||||
@@ -590,6 +576,20 @@ class Production(Base):
|
|||||||
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.8"
|
version = "0.1.7"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.10",
|
"easy_thumbnails==2.10",
|
||||||
"factory_boy==3.3.1",
|
"factory_boy==3.3.1",
|
||||||
|
"freezegun==1.5.1",
|
||||||
"gunicorn==23.0.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.23.0",
|
"jsonschema==4.23.0",
|
||||||
"june-analytics-python==2.3.0",
|
"june-analytics-python==2.3.0",
|
||||||
@@ -57,7 +58,8 @@ dependencies = [
|
|||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.7.0",
|
"livekit-api==0.7.0",
|
||||||
"aiohttp==3.10.10",
|
"minio==7.2.9",
|
||||||
|
"openai==1.51.2"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -70,7 +72,6 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2024.7.1",
|
"drf-spectacular-sidecar==2024.7.1",
|
||||||
"freezegun==1.5.1",
|
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.27.0",
|
"ipython==8.27.0",
|
||||||
"pyfakefs==5.6.0",
|
"pyfakefs==5.6.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine as frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ COPY .dockerignore ./.dockerignore
|
|||||||
COPY ./src/frontend/ .
|
COPY ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps AS meet
|
FROM frontend-deps as meet
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
FROM frontend-deps AS meet-dev
|
FROM frontend-deps as meet-dev
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ CMD [ "npm", "run", "dev"]
|
|||||||
|
|
||||||
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
|
||||||
# to avoid rebuilding the app at every changes.
|
# to avoid rebuilding the app at every changes.
|
||||||
FROM meet AS meet-builder
|
FROM meet as meet-builder
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
|
|||||||
Generated
+7
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.8",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.8",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.6.5",
|
"@livekit/components-react": "2.6.5",
|
||||||
"@livekit/components-styles": "1.1.3",
|
"@livekit/components-styles": "1.1.3",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"i18next-parser": "9.0.2",
|
"i18next-parser": "9.0.2",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.5.7",
|
"livekit-client": "2.5.7",
|
||||||
"posthog-js": "1.186.1",
|
"posthog-js": "1.167.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.4.0",
|
"react-aria-components": "1.4.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
@@ -409,7 +409,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"extraneous": true,
|
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4530,17 +4529,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
|
||||||
"version": "3.39.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
|
||||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/core-js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -8265,15 +8253,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.186.1",
|
"version": "1.167.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.186.1.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.167.0.tgz",
|
||||||
"integrity": "sha512-m6TNW01nfqErwMxaZxNScYdMaUJO0s3bbmt/tboL29yZDnuHdOiYFbG+T4MCxdFxjWRa5gOR25bQD/SSt1t/4A==",
|
"integrity": "sha512-/zXQ6tuJgiF1d4mgg3UsAi/uoyg7UnfFNQtikuALmaE53xFExpcAKbMfHPG/f54QgTvLxSHyGL1kFl/1uspkGg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.38.1",
|
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.19.3",
|
"preact": "^10.19.3",
|
||||||
"web-vitals": "^4.2.0"
|
"web-vitals": "^4.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.8",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"i18next-parser": "9.0.2",
|
"i18next-parser": "9.0.2",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.5.7",
|
"livekit-client": "2.5.7",
|
||||||
"posthog-js": "1.186.1",
|
"posthog-js": "1.167.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.4.0",
|
"react-aria-components": "1.4.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
|||||||
@@ -40,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
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { fetchApi } from './fetchApi'
|
|||||||
import { keys } from './queryKeys'
|
import { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
// todo - refactor it in a proper place
|
|
||||||
export enum RecordingMode {
|
|
||||||
Transcript = 'transcript',
|
|
||||||
ScreenRecording = 'screen_recording',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiConfig {
|
export interface ApiConfig {
|
||||||
analytics?: {
|
analytics?: {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,10 +11,6 @@ export interface ApiConfig {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
recording?: {
|
|
||||||
is_enabled?: boolean
|
|
||||||
available_modes?: RecordingMode[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
Binary file not shown.
|
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 |
@@ -0,0 +1,25 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiExternalLinkLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LinkButton } from '@/primitives'
|
||||||
|
|
||||||
|
export const Feedback = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<LinkButton
|
||||||
|
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||||
|
variant="success"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
|
||||||
|
💡
|
||||||
|
</span>
|
||||||
|
{t('feedbackAlert')}
|
||||||
|
<RiExternalLinkLine
|
||||||
|
size={16}
|
||||||
|
className={css({ marginLeft: 0.5 })}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</LinkButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
|
||||||
last_name: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,9 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
import { Button, Menu } 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 { useUser, UserAware } from '@/features/auth'
|
||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
@@ -13,132 +14,7 @@ import { usePersistentUserChoices } from '@livekit/components-react'
|
|||||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
import { 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,72 @@ 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" alignItems="start">
|
||||||
<Menu>
|
{isLoggedIn ? (
|
||||||
<Button variant="primary" data-attr="create-meeting">
|
<Menu>
|
||||||
{t('createMeeting')}
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
</Button>
|
{t('createMeeting')}
|
||||||
<RACMenu>
|
|
||||||
<MenuItem
|
|
||||||
className={menuItemRecipe({ icon: true })}
|
|
||||||
onAction={async () => {
|
|
||||||
const slug = generateRoomId()
|
|
||||||
createRoom({ slug, username }).then((data) =>
|
|
||||||
navigateTo('room', data.slug, {
|
|
||||||
state: { create: true, initialRoomData: data },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
data-attr="create-option-instant"
|
|
||||||
>
|
|
||||||
<RiAddLine size={18} />
|
|
||||||
{t('createMenu.instantOption')}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
className={menuItemRecipe({ icon: true })}
|
|
||||||
onAction={() => {
|
|
||||||
const slug = generateRoomId()
|
|
||||||
createRoom({ slug, username }).then((data) =>
|
|
||||||
setLaterRoomId(data.slug)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
data-attr="create-option-later"
|
|
||||||
>
|
|
||||||
<RiLink size={18} />
|
|
||||||
{t('createMenu.laterOption')}
|
|
||||||
</MenuItem>
|
|
||||||
</RACMenu>
|
|
||||||
</Menu>
|
|
||||||
) : (
|
|
||||||
<ProConnectButton hint={false} />
|
|
||||||
)}
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
outline
|
|
||||||
style={{
|
|
||||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('joinMeeting')}
|
|
||||||
</Button>
|
</Button>
|
||||||
<JoinMeetingDialog />
|
<RACMenu>
|
||||||
</DialogTrigger>
|
<MenuItem
|
||||||
</div>
|
className={menuItemRecipe({ icon: true })}
|
||||||
<Separator />
|
onAction={async () => {
|
||||||
<div
|
const slug = generateRoomId()
|
||||||
className={css({
|
createRoom({ slug, username }).then((data) =>
|
||||||
display: { base: 'none', lg: 'inline' },
|
navigateTo('room', data.slug, {
|
||||||
})}
|
state: { create: true, initialRoomData: data },
|
||||||
>
|
})
|
||||||
<MoreLink />
|
)
|
||||||
</div>
|
}}
|
||||||
</LeftColumn>
|
data-attr="create-option-instant"
|
||||||
<RightColumn>
|
>
|
||||||
<IntroSlider />
|
<RiAddLine size={18} />
|
||||||
<div
|
{t('createMenu.instantOption')}
|
||||||
className={css({
|
</MenuItem>
|
||||||
display: { base: 'inline', lg: 'none' },
|
<MenuItem
|
||||||
})}
|
className={menuItemRecipe({ icon: true })}
|
||||||
>
|
onAction={() => {
|
||||||
<MoreLink />
|
const slug = generateRoomId()
|
||||||
</div>
|
createRoom({ slug, username }).then((data) =>
|
||||||
</RightColumn>
|
setLaterRoomId(data.slug)
|
||||||
</Columns>
|
)
|
||||||
|
}}
|
||||||
|
data-attr="create-option-later"
|
||||||
|
>
|
||||||
|
<RiLink size={18} />
|
||||||
|
{t('createMenu.laterOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<ProConnectButton />
|
||||||
|
)}
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
outline
|
||||||
|
style={{
|
||||||
|
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('joinMeeting')}
|
||||||
|
</Button>
|
||||||
|
<JoinMeetingDialog />
|
||||||
|
</DialogTrigger>
|
||||||
|
</HStack>
|
||||||
|
</Centered>
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
roomId={laterRoomId || ''}
|
roomId={laterRoomId || ''}
|
||||||
onOpenChange={() => setLaterRoomId(null)}
|
onOpenChange={() => setLaterRoomId(null)}
|
||||||
|
|||||||
@@ -89,13 +89,6 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
|
||||||
// These settings help ensure successful connections in poor network conditions
|
|
||||||
const connectOptions = {
|
|
||||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
|
||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false}>
|
<Screen header={false}>
|
||||||
@@ -106,7 +99,6 @@ export const Conference = ({
|
|||||||
connect={true}
|
connect={true}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
connectOptions={connectOptions}
|
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
|
|||||||
@@ -2,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">
|
||||||
@@ -21,8 +19,7 @@ export const Join = ({
|
|||||||
micLabel={t('join.audioinput.label')}
|
micLabel={t('join.audioinput.label')}
|
||||||
camLabel={t('join.videoinput.label')}
|
camLabel={t('join.videoinput.label')}
|
||||||
joinLabel={t('join.joinLabel')}
|
joinLabel={t('join.joinLabel')}
|
||||||
userLabel={t('join.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)} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import { egressStore } from '@/stores/egress.ts'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
export const RecordingIndicator = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [isRecording, setIsRecording] = useState(room.isRecording)
|
||||||
|
|
||||||
|
const egressSnap = useSnapshot(egressStore)
|
||||||
|
const egressIsStopping = egressSnap.egressIsStopping
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRecordingStatusChanges = (isRecording: boolean) => {
|
||||||
|
if (!isRecording) {
|
||||||
|
egressStore.egressIsStopping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(isRecording)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
const getStatus = () => {
|
||||||
|
if (egressIsStopping) {
|
||||||
|
return 'saving recording'
|
||||||
|
}
|
||||||
|
if (isRecording) {
|
||||||
|
return 'recording'
|
||||||
|
}
|
||||||
|
if (!isRecording) {
|
||||||
|
return 'available'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Room status: {getStatus()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { useParams } from 'wouter'
|
||||||
|
|
||||||
|
export const useRecordRoom = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
const { roomId: roomSlug } = useParams()
|
||||||
|
|
||||||
|
const recordRoom = () => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
if (!roomSlug) {
|
||||||
|
throw new Error('Room ID is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'/twirp/livekit.Egress/StartRoomCompositeEgress'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_name: data.livekit.room,
|
||||||
|
audio_only: true,
|
||||||
|
file_outputs: [
|
||||||
|
{
|
||||||
|
file_extension: 'ogg',
|
||||||
|
filepath: `{room_name}_{time}_${roomSlug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecordingRoom = (egressId: string) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
egressId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recordRoom, stopRecordingRoom }
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button, TextArea } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { TextArea } from '@/primitives/TextArea'
|
||||||
import { RefObject } from 'react'
|
import { RefObject } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
|||||||
+2
@@ -10,6 +10,7 @@ import { Dispatch, SetStateAction } from 'react'
|
|||||||
import { DialogState } from './OptionsButton'
|
import { DialogState } from './OptionsButton'
|
||||||
import { Separator } from '@/primitives/Separator'
|
import { Separator } from '@/primitives/Separator'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
|
||||||
|
|
||||||
// @todo try refactoring it to use MenuList component
|
// @todo try refactoring it to use MenuList component
|
||||||
export const OptionsMenuItems = ({
|
export const OptionsMenuItems = ({
|
||||||
@@ -34,6 +35,7 @@ export const OptionsMenuItems = ({
|
|||||||
<RiAccountBoxLine size={20} />
|
<RiAccountBoxLine size={20} />
|
||||||
{t('effects')}
|
{t('effects')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<RecordingMenuItem />
|
||||||
</Section>
|
</Section>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
|
||||||
|
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
|
||||||
|
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { egressStore } from '@/stores/egress.ts'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
export const RecordingMenuItem = () => {
|
||||||
|
const { recordRoom, stopRecordingRoom } = useRecordRoom()
|
||||||
|
|
||||||
|
const egressSnap = useSnapshot(egressStore)
|
||||||
|
const egressId = egressSnap.egressId
|
||||||
|
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (egressId) {
|
||||||
|
setIsPending(true)
|
||||||
|
egressStore.egressIsStopping = true
|
||||||
|
const response = await stopRecordingRoom(egressId)
|
||||||
|
console.log(response)
|
||||||
|
egressStore.egressId = undefined
|
||||||
|
setIsPending(false)
|
||||||
|
} else {
|
||||||
|
setIsPending(true)
|
||||||
|
const response = await recordRoom()
|
||||||
|
egressStore.egressId = response['egress_id'] as string
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
isDisabled={isPending}
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={handleAction}
|
||||||
|
>
|
||||||
|
{egressId ? (
|
||||||
|
<>
|
||||||
|
<RiPauseCircleLine size={18} />
|
||||||
|
Arrêter l'enregistrement
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiRecordCircleLine size={18} />
|
||||||
|
Enregistrer la réunion
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
|||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -149,13 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
const { isSidePanelOpen } = useSidePanel()
|
const { isSidePanelOpen } = useSidePanel()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="lk-video-conference" {...props}>
|
||||||
className="lk-video-conference"
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
overflowX: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isWeb() && (
|
{isWeb() && (
|
||||||
<LayoutContextProvider
|
<LayoutContextProvider
|
||||||
value={layoutContext}
|
value={layoutContext}
|
||||||
@@ -171,6 +166,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<RecordingIndicator />
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
|
||||||
import { useLocation } from 'wouter'
|
|
||||||
|
|
||||||
// fixme - duplicated with home, refactor in a proper style
|
|
||||||
const Heading = styled('h1', {
|
|
||||||
base: {
|
|
||||||
fontWeight: '500',
|
|
||||||
fontStyle: 'normal',
|
|
||||||
fontStretch: 'normal',
|
|
||||||
fontOpticalSizing: 'auto',
|
|
||||||
fontSize: '2.3rem',
|
|
||||||
lineHeight: '2.5rem',
|
|
||||||
letterSpacing: '0',
|
|
||||||
paddingBottom: '2rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const FeedbackRoute = () => {
|
export const FeedbackRoute = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const [, setLocation] = useLocation()
|
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered">
|
<Screen layout="centered">
|
||||||
<Center>
|
<CenteredContent title={t('feedback.heading')} withBackButton>
|
||||||
<VStack>
|
<Text as="p" variant="h3" centered>
|
||||||
<Heading>{t('feedback.heading')}</Heading>
|
{t('feedback.body')}
|
||||||
<HStack>
|
</Text>
|
||||||
<Button
|
</CenteredContent>
|
||||||
outline
|
|
||||||
variant="primary"
|
|
||||||
onPress={() => window.history.back()}
|
|
||||||
>
|
|
||||||
{t('feedback.back')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
|
||||||
{t('feedback.home')}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
<Rating />
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
</Screen>
|
</Screen>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.full_name || user?.email }}
|
values={{ user: user?.email }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.full_name || user?.email }}
|
values={{ user: user?.email }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -13,14 +13,11 @@ i18n
|
|||||||
)
|
)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.init({
|
i18n.init({
|
||||||
supportedLngs: ['en', 'fr'],
|
supportedLngs: ['en', 'fr'],
|
||||||
fallbackLng: 'fr',
|
fallbackLng: 'en',
|
||||||
ns: i18nDefaultNamespace,
|
ns: i18nDefaultNamespace,
|
||||||
detection: {
|
interpolation: {
|
||||||
order: ['localStorage', 'navigator'],
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
interpolation: {
|
})
|
||||||
escapeValue: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Text, Button } from '@/primitives'
|
|||||||
import { SettingsButton } from '@/features/settings'
|
import { SettingsButton } from '@/features/settings'
|
||||||
import { logoutUrl, useUser } from '@/features/auth'
|
import { logoutUrl, useUser } from '@/features/auth'
|
||||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||||
import { FeedbackBanner } from '@/components/FeedbackBanner.tsx'
|
import { Feedback } from '@/components/Feedback'
|
||||||
import { Menu } from '@/primitives/Menu'
|
import { Menu } from '@/primitives/Menu'
|
||||||
import { MenuList } from '@/primitives/MenuList'
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||||
@@ -20,85 +20,80 @@ export const Header = () => {
|
|||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<FeedbackBanner />
|
className={css({
|
||||||
|
borderBottomColor: 'box.border',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
paddingY: 1,
|
||||||
|
paddingX: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
paddingY: 1,
|
display: 'flex',
|
||||||
paddingX: 1,
|
flexDirection: 'column',
|
||||||
flexShrink: 0,
|
rowGap: 1,
|
||||||
})}
|
md: {
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
rowGap: 0,
|
rowGap: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
})}
|
},
|
||||||
>
|
})}
|
||||||
<header>
|
>
|
||||||
<Stack gap={2.25} direction="row" align="center">
|
<header>
|
||||||
<Text bold variant="h1" margin={false}>
|
<Stack gap={2.25} direction="row" align="center">
|
||||||
<Link
|
<Text bold variant="h1" margin={false}>
|
||||||
onClick={(event) => {
|
<Link
|
||||||
if (
|
onClick={(event) => {
|
||||||
isRoom &&
|
if (
|
||||||
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
isRoom &&
|
||||||
) {
|
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
||||||
event.preventDefault()
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
to="/"
|
||||||
|
>
|
||||||
|
{t('app')}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Feedback />
|
||||||
|
</Stack>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<Stack gap={1} direction="row" align="center">
|
||||||
|
{isLoggedIn === false && !isHome && (
|
||||||
|
<ProConnectButton hint={false} />
|
||||||
|
)}
|
||||||
|
{!!user && (
|
||||||
|
<Menu>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
invisible
|
||||||
|
tooltip={t('loggedInUserTooltip')}
|
||||||
|
tooltipType="delayed"
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</Button>
|
||||||
|
<MenuList
|
||||||
|
items={[{ value: 'logout', label: t('logout') }]}
|
||||||
|
onAction={(value) => {
|
||||||
|
if (value === 'logout') {
|
||||||
|
terminateAnalyticsSession()
|
||||||
|
terminateSupportSession()
|
||||||
|
window.location.href = logoutUrl()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
to="/"
|
/>
|
||||||
>
|
</Menu>
|
||||||
{t('app')}
|
)}
|
||||||
</Link>
|
<SettingsButton />
|
||||||
</Text>
|
</Stack>
|
||||||
</Stack>
|
</nav>
|
||||||
</header>
|
|
||||||
<nav>
|
|
||||||
<Stack gap={1} direction="row" align="center">
|
|
||||||
{isLoggedIn === false && !isHome && (
|
|
||||||
<ProConnectButton hint={false} />
|
|
||||||
)}
|
|
||||||
{!!user && (
|
|
||||||
<Menu>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
invisible
|
|
||||||
tooltip={t('loggedInUserTooltip')}
|
|
||||||
tooltipType="delayed"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
display: 'block',
|
|
||||||
maxWidth: { base: '80px', xsm: '350px' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{user?.full_name || user?.email}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<MenuList
|
|
||||||
items={[{ value: 'logout', label: t('logout') }]}
|
|
||||||
onAction={(value) => {
|
|
||||||
if (value === 'logout') {
|
|
||||||
terminateAnalyticsSession()
|
|
||||||
terminateSupportSession()
|
|
||||||
window.location.href = logoutUrl()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
<SettingsButton />
|
|
||||||
</Stack>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedbackAlert": "",
|
||||||
"context": "",
|
|
||||||
"cta": ""
|
|
||||||
},
|
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
"joinMeetingTipContent": "",
|
"joinMeetingTipContent": "",
|
||||||
"joinMeetingTipHeading": "",
|
"joinMeetingTipHeading": "",
|
||||||
"loginToCreateMeeting": "",
|
"loginToCreateMeeting": "",
|
||||||
"moreLinkLabel": "",
|
|
||||||
"moreLink": "",
|
|
||||||
"moreAbout": "",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "",
|
"laterOption": "",
|
||||||
"instantOption": ""
|
"instantOption": ""
|
||||||
@@ -23,34 +20,5 @@
|
|||||||
"copy": "",
|
"copy": "",
|
||||||
"copied": "",
|
"copied": "",
|
||||||
"permissions": ""
|
"permissions": ""
|
||||||
},
|
|
||||||
"introSlider": {
|
|
||||||
"previous": {
|
|
||||||
"label": "",
|
|
||||||
"tooltip": ""
|
|
||||||
},
|
|
||||||
"beta": {
|
|
||||||
"text": "",
|
|
||||||
"tooltip": ""
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"label": "",
|
|
||||||
"tooltip": ""
|
|
||||||
},
|
|
||||||
"slide1": {
|
|
||||||
"title": "",
|
|
||||||
"body": "",
|
|
||||||
"imgAlt": ""
|
|
||||||
},
|
|
||||||
"slide2": {
|
|
||||||
"title": "",
|
|
||||||
"body": "",
|
|
||||||
"imgAlt": ""
|
|
||||||
},
|
|
||||||
"slide3": {
|
|
||||||
"title": "",
|
|
||||||
"body": "",
|
|
||||||
"imgAlt": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "",
|
"body": "",
|
||||||
"home": "",
|
"heading": ""
|
||||||
"back": ""
|
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"joinMeeting": "",
|
"joinMeeting": "",
|
||||||
"toggleOff": "",
|
"toggleOff": "",
|
||||||
"toggleOn": "",
|
"toggleOn": "",
|
||||||
|
"userLabel": "",
|
||||||
"usernameHint": "",
|
"usernameHint": "",
|
||||||
"usernameLabel": ""
|
"usernameLabel": ""
|
||||||
},
|
},
|
||||||
@@ -110,24 +110,6 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": ""
|
"disclaimer": ""
|
||||||
},
|
},
|
||||||
"rating": {
|
|
||||||
"submit": "",
|
|
||||||
"question": "",
|
|
||||||
"levels": {
|
|
||||||
"min": "",
|
|
||||||
"max": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"openFeedback": {
|
|
||||||
"question": "",
|
|
||||||
"placeholder": "",
|
|
||||||
"submit": "",
|
|
||||||
"skip": ""
|
|
||||||
},
|
|
||||||
"confirmationMessage": {
|
|
||||||
"heading": "",
|
|
||||||
"body": ""
|
|
||||||
},
|
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "",
|
"subheading": "",
|
||||||
"contributors": "",
|
"contributors": "",
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": "An error occurred while loading the page"
|
"heading": "An error occurred while loading the page"
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedbackAlert": "Give us feedback",
|
||||||
"context": "Visio is still in early development — your input matters!",
|
|
||||||
"cta": "Share your feedback"
|
|
||||||
},
|
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "You don't have the permission to view this page"
|
"heading": "You don't have the permission to view this page"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"createMeeting": "Create a meeting",
|
"createMeeting": "Create a meeting",
|
||||||
"heading": "Simple and Secure Video Conferencing",
|
"heading": "Welcome in Visio",
|
||||||
"intro": "Communicate and work with ease, without compromising your sovereignty",
|
"intro": "Work easily, from anywhere.",
|
||||||
"joinInputError": "Use a meeting link or code. Examples:",
|
"joinInputError": "Use a meeting link or code. Examples:",
|
||||||
"joinInputExample": "URL or 10-letter code",
|
"joinInputExample": "URL or 10-letter code",
|
||||||
"joinInputLabel": "Meeting link",
|
"joinInputLabel": "Meeting link",
|
||||||
@@ -10,9 +10,6 @@
|
|||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting",
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
"moreLinkLabel": "Learn more - new tab",
|
|
||||||
"moreLink": "Learn more",
|
|
||||||
"moreAbout": "about Visio",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Create a meeting for a later date",
|
"laterOption": "Create a meeting for a later date",
|
||||||
"instantOption": "Start an instant meeting"
|
"instantOption": "Start an instant meeting"
|
||||||
@@ -23,34 +20,5 @@
|
|||||||
"copy": "Copy the meeting link",
|
"copy": "Copy the meeting link",
|
||||||
"copied": "Link copied to clipboard",
|
"copied": "Link copied to clipboard",
|
||||||
"permissions": "People with this link do not need your permission to join this meeting."
|
"permissions": "People with this link do not need your permission to join this meeting."
|
||||||
},
|
|
||||||
"introSlider": {
|
|
||||||
"previous": {
|
|
||||||
"label": "previous",
|
|
||||||
"tooltip": "previous"
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"label": "next",
|
|
||||||
"tooltip": "next"
|
|
||||||
},
|
|
||||||
"beta": {
|
|
||||||
"text": "Join the beta",
|
|
||||||
"tooltip": "Fill out the form"
|
|
||||||
},
|
|
||||||
"slide1": {
|
|
||||||
"title": "Try Visio to simplify your daily tasks",
|
|
||||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
|
|
||||||
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
|
|
||||||
},
|
|
||||||
"slide2": {
|
|
||||||
"title": "Host group calls without limits",
|
|
||||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
|
|
||||||
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
|
|
||||||
},
|
|
||||||
"slide3": {
|
|
||||||
"title": "Transform your meetings with AI",
|
|
||||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
|
|
||||||
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "You have left the meeting",
|
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
|
||||||
"home": "Return to home",
|
"heading": "Help us improve Visio"
|
||||||
"back": "Rejoin the meeting"
|
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"joinMeeting": "Join meeting",
|
"joinMeeting": "Join meeting",
|
||||||
"toggleOff": "Click to turn off",
|
"toggleOff": "Click to turn off",
|
||||||
"toggleOn": "Click to turn on",
|
"toggleOn": "Click to turn on",
|
||||||
|
"userLabel": "",
|
||||||
"usernameHint": "Shown to other participants",
|
"usernameHint": "Shown to other participants",
|
||||||
"usernameLabel": "Your name"
|
"usernameLabel": "Your name"
|
||||||
},
|
},
|
||||||
@@ -108,24 +108,6 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
||||||
},
|
},
|
||||||
"rating": {
|
|
||||||
"submit": "Submit",
|
|
||||||
"question": "What do you think about the quality of your call?",
|
|
||||||
"levels": {
|
|
||||||
"min": "very poor",
|
|
||||||
"max": "excellent"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"openFeedback": {
|
|
||||||
"question": "What can we do to improve Visio?",
|
|
||||||
"placeholder": "Describe your bugs or share your suggestions…",
|
|
||||||
"submit": "Submit",
|
|
||||||
"skip": "Skip"
|
|
||||||
},
|
|
||||||
"confirmationMessage": {
|
|
||||||
"heading": "Thank you for your submission",
|
|
||||||
"body": "Our product team takes the time to carefully review your feedback. We will get back to you as soon as possible."
|
|
||||||
},
|
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "In room",
|
"subheading": "In room",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedbackAlert": "Donnez-nous votre avis",
|
||||||
"context": "Visio est en pleine construction — votre avis compte !",
|
|
||||||
"cta": "Partagez votre avis"
|
|
||||||
},
|
|
||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "Accès interdit"
|
"heading": "Accès interdit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"createMeeting": "Créer une réunion",
|
"createMeeting": "Créer une réunion",
|
||||||
"heading": "Visioconférences simples et sécurisées",
|
"heading": "Visio",
|
||||||
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
|
"intro": "Collaborez en toute simplicité, où que vous soyez.",
|
||||||
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
|
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
|
||||||
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
|
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
|
||||||
"joinInputLabel": "Lien complet ou code de la réunion",
|
"joinInputLabel": "Lien complet ou code de la réunion",
|
||||||
@@ -10,9 +10,6 @@
|
|||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
|
||||||
"moreLink": "En savoir plus",
|
|
||||||
"moreAbout": "sur Visio",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
"instantOption": "Démarrer une réunion instantanée"
|
"instantOption": "Démarrer une réunion instantanée"
|
||||||
@@ -23,34 +20,5 @@
|
|||||||
"copy": "Copier le lien de la réunion",
|
"copy": "Copier le lien de la réunion",
|
||||||
"copied": "Lien copié dans le presse-papiers",
|
"copied": "Lien copié dans le presse-papiers",
|
||||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||||
},
|
|
||||||
"introSlider": {
|
|
||||||
"previous": {
|
|
||||||
"label": "précédent",
|
|
||||||
"tooltip": "précédent"
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"label": "suivant",
|
|
||||||
"tooltip": "suivant"
|
|
||||||
},
|
|
||||||
"beta": {
|
|
||||||
"text": "Essayer la beta",
|
|
||||||
"tooltip": "Accéder au formulaire"
|
|
||||||
},
|
|
||||||
"slide1": {
|
|
||||||
"title": "Essayez Visio pour simplifier votre quotidien",
|
|
||||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
|
|
||||||
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
|
|
||||||
},
|
|
||||||
"slide2": {
|
|
||||||
"title": "Organisez des appels de groupe sans limite",
|
|
||||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
|
|
||||||
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
|
|
||||||
},
|
|
||||||
"slide3": {
|
|
||||||
"title": "Transformez vos réunions avec l'IA",
|
|
||||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
|
|
||||||
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "Vous avez quitté la réunion",
|
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
|
||||||
"home": "Retourner à l'accueil",
|
"heading": "Aidez-nous à améliorer Visio"
|
||||||
"back": "Réintégrer la réunion"
|
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"joinMeeting": "Rejoindre la réjoindre",
|
"joinMeeting": "Rejoindre la réjoindre",
|
||||||
"toggleOff": "Cliquez pour désactiver",
|
"toggleOff": "Cliquez pour désactiver",
|
||||||
"toggleOn": "Cliquez pour activer",
|
"toggleOn": "Cliquez pour activer",
|
||||||
|
"userLabel": "",
|
||||||
"usernameHint": "Affiché aux autres participants",
|
"usernameHint": "Affiché aux autres participants",
|
||||||
"usernameLabel": "Votre nom"
|
"usernameLabel": "Votre nom"
|
||||||
},
|
},
|
||||||
@@ -108,24 +108,6 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
||||||
},
|
},
|
||||||
"rating": {
|
|
||||||
"submit": "Envoyer",
|
|
||||||
"question": "Que pensez-vous de la qualité de votre appel ?",
|
|
||||||
"levels": {
|
|
||||||
"min": "très mauvaise",
|
|
||||||
"max": "excellente"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"openFeedback": {
|
|
||||||
"question": "Que pouvons-nous faire pour améliorer Visio ?",
|
|
||||||
"placeholder": "Décrivez vos bugs ou partagez vos suggestions …",
|
|
||||||
"submit": "Envoyer",
|
|
||||||
"skip": "Passer"
|
|
||||||
},
|
|
||||||
"confirmationMessage": {
|
|
||||||
"heading": "Merci pour votre submission",
|
|
||||||
"body": "Notre équipe produit prend le temps d'analyser attentivement vos réponses. Nous reviendrons vers vous dans les plus brefs délais."
|
|
||||||
},
|
|
||||||
"participants": {
|
"participants": {
|
||||||
"subheading": "Dans la réunion",
|
"subheading": "Dans la réunion",
|
||||||
"you": "Vous",
|
"you": "Vous",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const link = cva({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&[data-hovered]': {
|
'&[data-hovered]': {
|
||||||
textDecorationThickness: '2px',
|
textDecoration: 'none',
|
||||||
},
|
},
|
||||||
'&[data-pressed]': {
|
'&[data-pressed]': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ export const buttonRecipe = cva({
|
|||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
colorPalette: 'primary',
|
colorPalette: 'primary',
|
||||||
'&[data-disabled]': {
|
|
||||||
opacity: 0.3,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// @TODO: better handling of colors… this is a mess
|
// @TODO: better handling of colors… this is a mess
|
||||||
success: {
|
success: {
|
||||||
|
|||||||
@@ -28,4 +28,3 @@ export { Text } from './Text'
|
|||||||
export { ToggleButton } from './ToggleButton'
|
export { ToggleButton } from './ToggleButton'
|
||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
export { TextArea } from './TextArea'
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
egressId: string | undefined
|
||||||
|
egressIsStopping: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const egressStore = proxy<State>({
|
||||||
|
egressId: undefined,
|
||||||
|
egressIsStopping: false,
|
||||||
|
})
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
replicaCount: 1
|
|
||||||
terminationGracePeriodSeconds: 18000
|
|
||||||
|
|
||||||
egress:
|
|
||||||
log_level: debug
|
|
||||||
ws_url: ws://livekit-livekit-server:80
|
|
||||||
insecure: true
|
|
||||||
enable_chrome_sandbox: true
|
|
||||||
{{- with .Values.livekit.keys }}
|
|
||||||
{{- range $key, $value := . }}
|
|
||||||
api_key: {{ $key }}
|
|
||||||
api_secret: {{ $value }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
redis:
|
|
||||||
address: redis-master:6379
|
|
||||||
password: pass
|
|
||||||
s3:
|
|
||||||
access_key: meet
|
|
||||||
secret: password
|
|
||||||
region: local
|
|
||||||
bucket: meet-media-storage
|
|
||||||
endpoint: http://minio:9000
|
|
||||||
force_path_style: true
|
|
||||||
|
|
||||||
loadBalancer:
|
|
||||||
type: nginx
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- livekit-egress.127.0.0.1.nip.io
|
|
||||||
secretName: livekit-egress-dinum-cert
|
|
||||||
|
|
||||||
autoscaling:
|
|
||||||
enabled: false
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 5
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
resources: {}
|
|
||||||
@@ -4,20 +4,13 @@ terminationGracePeriodSeconds: 18000
|
|||||||
livekit:
|
livekit:
|
||||||
log_level: debug
|
log_level: debug
|
||||||
rtc:
|
rtc:
|
||||||
use_external_ip: false
|
use_external_ip: true
|
||||||
port_range_start: 50000
|
port_range_start: 50000
|
||||||
port_range_end: 60000
|
port_range_end: 60000
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
redis:
|
redis:
|
||||||
address: redis-master:6379
|
address:
|
||||||
password: pass
|
|
||||||
keys:
|
keys:
|
||||||
turn:
|
|
||||||
enabled: true
|
|
||||||
udp_port: 443
|
|
||||||
domain: livekit.127.0.0.1.nip.io
|
|
||||||
loadBalancerAnnotations: {}
|
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
type: nginx
|
type: nginx
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ backend:
|
|||||||
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
|
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
|
||||||
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email given_name usual_name"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
@@ -47,13 +47,10 @@ backend:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||||
|
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
|
||||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user