Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af214d582d | |||
| bb4a863f8d | |||
| 0241f67787 | |||
| 908bbb828a | |||
| c7f5dabbad | |||
| a642c6d9a2 | |||
| a6dc12d91c | |||
| 307987d94d | |||
| d7ebdbf401 | |||
| dad396273c | |||
| 555daedeba | |||
| 0d09d1df08 | |||
| a40af726b6 | |||
| f8a37e55b1 | |||
| 3baec0a863 | |||
| 5b6ed6bbf0 | |||
| aea01636cf | |||
| e4c2b42e4a | |||
| 36ba0f9c8e | |||
| 2d6fe6ee7d | |||
| e2fcf7dd2c | |||
| 9f9cef7e2a | |||
| b403ac56bf | |||
| baf378d53d | |||
| 990507e3c7 | |||
| 6cd54f7e1e | |||
| 315d48a501 | |||
| 2f7b56f918 | |||
| 53e68b7780 | |||
| 10eda5c2ea | |||
| ba3b3fe0ba | |||
| 0c3bcd81c9 | |||
| dbc66c2f07 | |||
| 39be4697b0 | |||
| 2443fa63a5 | |||
| 214dc87b1f | |||
| 3dc23be101 | |||
| 6b5e8081bc | |||
| df671ea994 | |||
| 06a5b9b17e | |||
| 59d4c2583b | |||
| 4b80b4ac9f | |||
| 96d7a8875b | |||
| dc177b69d8 | |||
| 36b2156c7b |
@@ -14,6 +14,8 @@ on:
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
@@ -27,7 +29,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-backend
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -40,7 +42,7 @@ jobs:
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -63,7 +65,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-frontend
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -76,7 +78,7 @@ jobs:
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -100,7 +102,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-frontend-dinum
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -113,7 +115,7 @@ jobs:
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -137,7 +139,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-summary
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -183,6 +183,10 @@ jobs:
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
OIDC_RS_CLIENT_ID: meet
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
OIDC_OP_URL: https://oidc.example.com
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
+10
@@ -90,6 +90,9 @@ services:
|
||||
- createwebhook
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -145,6 +148,9 @@ services:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
@@ -298,3 +304,7 @@ services:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./src/summary
|
||||
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN npm run build
|
||||
# Inject PostHog sourcemap metadata into the built assets
|
||||
# This metadata is essential for correctly mapping errors to source maps in production
|
||||
RUN set -e && \
|
||||
npx @posthog/cli sourcemap inject --directory ./dist/assets
|
||||
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
|
||||
|
||||
COPY ./docker/dinum-frontend/dinum-styles.css \
|
||||
./dist/assets/
|
||||
@@ -42,7 +42,12 @@ COPY ./docker/dinum-frontend/fonts/ \
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
|
||||
RUN apk update && apk upgrade libssl3 \
|
||||
libcrypto3 \
|
||||
libxml2>=2.12.7-r2 \
|
||||
libxslt>=1.1.39-r2 \
|
||||
libexpat>=2.7.2-r0 \
|
||||
libpng>=1.6.53-r0
|
||||
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
upstream meet_backend {
|
||||
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
upstream meet_frontend {
|
||||
server ${FRONTEND_HOST}:8080 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Disables server version feedback on pages and in headers
|
||||
server_tokens off;
|
||||
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
location @proxy_to_meet_backend {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://meet_backend;
|
||||
}
|
||||
|
||||
location @proxy_to_meet_frontend {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://meet_frontend;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri @proxy_to_meet_frontend;
|
||||
}
|
||||
|
||||
location /api {
|
||||
try_files $uri @proxy_to_meet_backend;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
try_files $uri @proxy_to_meet_backend;
|
||||
}
|
||||
|
||||
location /static {
|
||||
try_files $uri @proxy_to_meet_backend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
|
||||
# You can add any necessary service here that will join the same docker network
|
||||
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
||||
# able to communicate with keycloak and the backend on that network.
|
||||
services:
|
||||
# busybox service is only used for testing purposes. It provides curl to test
|
||||
# connectivity to the backend and keycloak services. Replace this with your
|
||||
# relevant application services that need to communicate with keycloak.
|
||||
busybox:
|
||||
image: alpine:latest
|
||||
privileged: true
|
||||
command: sh -c "apk add --no-cache curl && sleep infinity"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
networks:
|
||||
- default
|
||||
- meet_resource-server
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
meet_resource-server:
|
||||
external: true
|
||||
@@ -0,0 +1,88 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
volumes:
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
backend:
|
||||
image: lasuite/meet-backend:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
restart: always
|
||||
env_file:
|
||||
- env.d/common
|
||||
- env.d/backend
|
||||
- env.d/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "manage.py", "check"]
|
||||
interval: 15s
|
||||
timeout: 30s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
livekit:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
image: lasuite/meet-frontend:latest
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
entrypoint:
|
||||
- /docker-entrypoint.sh
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
env_file:
|
||||
- env.d/common
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /config.yaml
|
||||
ports:
|
||||
- 7881:7881/tcp
|
||||
- 7882:7882/udp
|
||||
volumes:
|
||||
- ./livekit-server.yaml:/config.yaml
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_PORT=7880 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
|
||||
# Uncomment if using our nginx proxy example
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
@@ -0,0 +1,92 @@
|
||||
# Deploy and Configure Keycloak for Meet
|
||||
|
||||
## Installation
|
||||
|
||||
> [!CAUTION]
|
||||
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
|
||||
|
||||
### Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir keycloak/env.d && cd keycloak
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
|
||||
```
|
||||
|
||||
### Step 2:. Update `env.d/` files
|
||||
|
||||
The following variables need to be updated with your own values, others can be left as is:
|
||||
|
||||
```env
|
||||
POSTGRES_PASSWORD=<generate postgres password>
|
||||
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||
```
|
||||
|
||||
### Step 3: Expose keycloak instance on https
|
||||
|
||||
> [!NOTE]
|
||||
> You can skip this section if you already have your own setup.
|
||||
|
||||
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
|
||||
|
||||
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
keycloak:
|
||||
...
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||
...
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
|
||||
# Uncomment if using our nginx proxy example
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
```
|
||||
|
||||
### Step 4: Start the service
|
||||
|
||||
```bash
|
||||
`docker compose up -d`
|
||||
```
|
||||
|
||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
|
||||
> [!CAUTION]
|
||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||
```
|
||||
|
||||
## Creating an OIDC Client for Meet Application
|
||||
|
||||
### Step 1: Create a New Realm
|
||||
|
||||
1. Log in to the Keycloak administration console.
|
||||
2. Navigate to the realm tab and click on the "Create realm" button.
|
||||
3. Enter the name of the realm - `meet`.
|
||||
4. Click "Create".
|
||||
|
||||
#### Step 2: Create a New Client
|
||||
|
||||
1. Navigate to the "Clients" tab.
|
||||
2. Click on the "Create client" button.
|
||||
3. Enter the client ID - e.g. `meet`.
|
||||
4. Enable "Client authentication" option.
|
||||
6. Set the "Valid redirect URIs" to the URL of your meet application suffixed with `/*` - e.g., "https://meet.example.com/*".
|
||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||
1. Click "Save".
|
||||
|
||||
#### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/kc_postgresql
|
||||
volumes:
|
||||
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
command: ["start"]
|
||||
env_file:
|
||||
- env.d/kc_postgresql
|
||||
- env.d/keycloak
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
#
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
@@ -0,0 +1,39 @@
|
||||
# Nginx proxy with automatic SSL certificates
|
||||
|
||||
> [!CAUTION]
|
||||
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
|
||||
|
||||
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
|
||||
|
||||
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir nginx-proxy && cd nginx-proxy
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||
```
|
||||
|
||||
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||
|
||||
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
|
||||
|
||||
### Step 3: Create docker network
|
||||
|
||||
Containers need share the same network for auto-discovery.
|
||||
|
||||
```bash
|
||||
docker network create proxy-tier
|
||||
```
|
||||
|
||||
### Step 4: Start service
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
nginx-proxy:
|
||||
image: nginxproxy/nginx-proxy
|
||||
container_name: nginx-proxy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- html:/usr/share/nginx/html
|
||||
- certs:/etc/nginx/certs:ro
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
networks:
|
||||
- proxy-tier
|
||||
|
||||
acme-companion:
|
||||
image: nginxproxy/acme-companion
|
||||
container_name: nginx-proxy-acme
|
||||
environment:
|
||||
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||
volumes_from:
|
||||
- nginx-proxy
|
||||
volumes:
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- acme:/etc/acme.sh
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- proxy-tier
|
||||
|
||||
networks:
|
||||
proxy-tier:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
html:
|
||||
certs:
|
||||
acme:
|
||||
@@ -0,0 +1,22 @@
|
||||
port: 7880
|
||||
redis:
|
||||
address: redis:6379
|
||||
keys:
|
||||
meet: <your livekit secret key>
|
||||
# WebRTC configuration
|
||||
rtc:
|
||||
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
|
||||
# # listed port(s). To maximize system performance, we recommend using a range of ports
|
||||
# # greater or equal to the number of vCPUs on the machine.
|
||||
# # port_range_start & end must not be set for this config to take effect
|
||||
udp_port: 7882
|
||||
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
|
||||
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
|
||||
# WebRTC transports are encrypted and do not require additional encryption
|
||||
# only 80/443 on public IP are allowed if less than 1024
|
||||
tcp_port: 7881
|
||||
# use_external_ip should be set to true for most cloud environments where
|
||||
# the host has a public IP address, but is not exposed to the process.
|
||||
# LiveKit will attempt to use STUN to discover the true IP, and advertise
|
||||
# that IP with its clients
|
||||
use_external_ip: true
|
||||
@@ -6,12 +6,9 @@ Here are a bunch of resources to help you install the project.
|
||||
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
|
||||
|
||||
## Docker Compose
|
||||
We understand that not everyone has a Kubernetes cluster available.
|
||||
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
|
||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||
|
||||
> [!WARNING]
|
||||
> Under construction: A PR is in progress to support deploying La Suite Meet via Docker Compose.
|
||||
|
||||
## Other ways to install La Suite Meet
|
||||
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
||||
|
||||
## Requirements
|
||||
|
||||
All services are required to run the minimalist instance of LaSuite Meet. Click the links for ready-to-use configuration examples:
|
||||
|
||||
| Service | Purpose | Example Config |
|
||||
|-------------------|---------|----------------------------------------------------------|
|
||||
| **PostgreSQL** | Main database | [compose.yaml](../examples/compose/compose.yaml) |
|
||||
| **Redis** | Cache & sessions | [compose.yaml](../examples/compose/compose.yaml) |
|
||||
| **Livekit** | Real-time communication | [compose.yaml](../examples/compose/compose.yaml) |
|
||||
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
|
||||
| **SMTP Service** | Email notifications | - |
|
||||
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
|
||||
|
||||
|
||||
## Software Requirements
|
||||
|
||||
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
|
||||
|
||||
Docker Compose is included with Docker Engine:
|
||||
|
||||
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
|
||||
for [installing Docker Engine](https://docs.docker.com/engine/install/).
|
||||
|
||||
For older versions of Docker Engine that do not include Docker Compose:
|
||||
|
||||
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
|
||||
|
||||
> [!NOTE]
|
||||
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
|
||||
|
||||
## Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir -p meet/env.d && cd meet
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/compose.yaml
|
||||
curl -o .env https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/hosts
|
||||
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/common
|
||||
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/postgresql
|
||||
curl -o livekit-server.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/livekit/server.yaml
|
||||
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docker/files/production/default.conf.template
|
||||
```
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||
|
||||
In this example, we assume the following services:
|
||||
|
||||
- OIDC provider on https://id.yourdomain.tld
|
||||
- Livekit server on https://livekit.yourdomain.tld
|
||||
- Meet server on https://meet.yourdomain.tld
|
||||
|
||||
**Set your own values in `.env`**
|
||||
|
||||
### OIDC
|
||||
|
||||
Authentication in Meet is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
|
||||
|
||||
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
|
||||
|
||||
If using Keycloak as your Identity Provider, in `env.d/common` set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Meet. By default we have set `meet` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `.env`
|
||||
|
||||
For others OIDC providers, update the variables in `env.d/common`.
|
||||
|
||||
### Postgresql
|
||||
|
||||
Meet uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
|
||||
|
||||
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
|
||||
|
||||
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
|
||||
|
||||
### Redis
|
||||
|
||||
Meet uses Redis for caching and inter-service communication. While an external Redis can be used, our example provides a deployment method.
|
||||
|
||||
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/common`.
|
||||
|
||||
### Livekit
|
||||
|
||||
[LiveKit](https://github.com/livekit/livekit) server is used as the WebRTC SFU (Selective Forwarding Unit) allowing multi-user conferencing. For more information, head to [livekit documentation](https://docs.livekit.io/home/self-hosting/).
|
||||
|
||||
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||
|
||||
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||
|
||||
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
||||
|
||||
> [!NOTE]
|
||||
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
|
||||
|
||||
### Meet
|
||||
|
||||
The Meet backend is built on the Django Framework.
|
||||
|
||||
Generate a [secure key](https://docs.djangoproject.com/en/5.2/ref/settings/#secret-key.) for `DJANGO_SECRET_KEY` in `env.d/common`.
|
||||
|
||||
### Mail
|
||||
|
||||
The following environment variables are required in `env.d/common` for the mail service to send invitations :
|
||||
|
||||
```env
|
||||
DJANGO_EMAIL_HOST=<smtp host>
|
||||
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||
DJANGO_EMAIL_PORT=<smtp port>
|
||||
DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
```
|
||||
|
||||
## Step 3: Configure your firewall
|
||||
|
||||
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||
|
||||
The following ports will need to be opened:
|
||||
- 80/tcp - for TLS issuance
|
||||
- 443/tcp - for listening on HTTPS and TURN/TLS packets
|
||||
- 7881/tcp - WebRTC ICE over TCP
|
||||
- 7882/udp - for WebRTC multiplexing over UDP
|
||||
|
||||
If you are using ufw, enter the follwoing:
|
||||
```
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw allow 443/udp
|
||||
ufw allow 7881/tcp
|
||||
ufw allow 7882/udp
|
||||
ufw enable
|
||||
```
|
||||
|
||||
## Step 4: Reverse proxy and SSL/TLS
|
||||
|
||||
> [!WARNING]
|
||||
> In a production environment, configure SSL/TLS termination to run your instance on https.
|
||||
|
||||
If you have your own certificates and proxy setup, you can skip this part.
|
||||
|
||||
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
|
||||
|
||||
You will need to uncomment the environment and network sections in compose file and update it with your values.
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
...
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
|
||||
...
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
...
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_PORT=7880 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - default
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
```
|
||||
|
||||
## Step 5: Start Meet
|
||||
|
||||
You are ready to start your Meet application !
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
> [!NOTE]
|
||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
|
||||
|
||||
## Step 6: Run the database migration and create Django admin user
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
|
||||
```
|
||||
|
||||
Replace `<admin email>` with the email of your admin user and generate a secure password.
|
||||
|
||||
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
||||
|
||||
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
|
||||
## How to upgrade your Meet application
|
||||
|
||||
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
|
||||
|
||||
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
|
||||
|
||||
### Step 1: Edit the images tag with the desired version
|
||||
|
||||
### Step 2: Pull the images
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
### Step 3: Restart your containers
|
||||
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Step 4: Run the database migration
|
||||
Your database schema may need to be updated, run:
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
```
|
||||
+3
-3
@@ -7,7 +7,7 @@ info:
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
|
||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
|
||||
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
|
||||
3. Tokens are scoped and allow applications to act on behalf of specific users.
|
||||
|
||||
@@ -40,7 +40,7 @@ tags:
|
||||
description: Room management operations
|
||||
|
||||
paths:
|
||||
/applications/token:
|
||||
/application/token:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
@@ -283,7 +283,7 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: |
|
||||
JWT token obtained from the `/applications/token` endpoint.
|
||||
JWT token obtained from the `/application/token` endpoint.
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Meet External API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
External API for room management with resource server authentication.
|
||||
[[description by Oauth 2.0]](https://www.oauth.com/oauth2-servers/the-resource-server/)
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
1. Authenticate with the authorization server using your credentials
|
||||
2. During authentication, request the scopes you need: `lasuite_visio` (mandatory) plus action-specific scopes
|
||||
3. Receive an access token and a refresh token that includes the requested scopes
|
||||
4. Use the access token in the `Authorization: Bearer <token>` header for all API requests
|
||||
5. When the access token expires, use the refresh token to obtain a new access token without re-authenticating
|
||||
|
||||
#### Scopes
|
||||
|
||||
* `lasuite_visio` - **Mandatory** Base scope required for any API access
|
||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
||||
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
#### Upcoming Features
|
||||
|
||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||
|
||||
contact:
|
||||
name: API Support
|
||||
email: antoine.lebaud@mail.numerique.gouv.fr
|
||||
|
||||
servers:
|
||||
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
|
||||
description: Sandbox server
|
||||
|
||||
tags:
|
||||
- name: Rooms
|
||||
description: Room management operations
|
||||
|
||||
paths:
|
||||
/rooms:
|
||||
get:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: List rooms
|
||||
description: |
|
||||
Returns a list of rooms accessible to the authenticated user.
|
||||
Only rooms where the delegated user has access will be returned.
|
||||
operationId: listRooms
|
||||
security:
|
||||
- BearerAuth: [rooms:list]
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number for pagination
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of items per page
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: List of accessible rooms
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total number of rooms
|
||||
next:
|
||||
type: string
|
||||
nullable: true
|
||||
description: URL to next page
|
||||
previous:
|
||||
type: string
|
||||
nullable: true
|
||||
description: URL to previous page
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Room'
|
||||
examples:
|
||||
roomList:
|
||||
summary: Paginated room list
|
||||
value:
|
||||
count: 2
|
||||
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
|
||||
previous: null
|
||||
results:
|
||||
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug: "aae-erez-aaz"
|
||||
access_level: "trusted"
|
||||
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||
telephony:
|
||||
enabled: true
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Create a room
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
**Defaults:**
|
||||
- Delegated user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
- Telephony PIN auto-generated when enabled
|
||||
- Creation tracked with application client_id for auditing
|
||||
operationId: createRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:create]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Retrieve a room
|
||||
description: Get detailed information about a specific room by its ID
|
||||
operationId: retrieveRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:retrieve]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Room details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
examples:
|
||||
room:
|
||||
summary: Room details
|
||||
value:
|
||||
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug: "aae-erez-aaz"
|
||||
access_level: "trusted"
|
||||
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||
telephony:
|
||||
enabled: true
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: |
|
||||
JWT token obtained from the `/application/token` endpoint.
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
TokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- grant_type
|
||||
- scope
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: Application client identifier
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret:
|
||||
type: string
|
||||
format: password
|
||||
writeOnly: true
|
||||
description: Application secret key
|
||||
example: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type:
|
||||
type: string
|
||||
enum:
|
||||
- client_credentials
|
||||
description: OAuth2 grant type (must be 'client_credentials')
|
||||
example: "client_credentials"
|
||||
scope:
|
||||
type: string
|
||||
format: email
|
||||
description: |
|
||||
Email address of the user to delegate.
|
||||
The application will act on behalf of this user.
|
||||
Note: This parameter is named 'scope' to align with OAuth2 conventions,
|
||||
but accepts an email address to identify the user. This design allows
|
||||
for future extensibility.
|
||||
example: "user@example.com"
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
description: JWT access token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
|
||||
token_type:
|
||||
type: string
|
||||
description: Token type (always 'Bearer')
|
||||
example: "Bearer"
|
||||
expires_in:
|
||||
type: integer
|
||||
description: Token lifetime in seconds
|
||||
example: 3600
|
||||
scope:
|
||||
type: string
|
||||
description: Space-separated list of granted permission scopes
|
||||
example: "rooms:list rooms:retrieve rooms:create"
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
|
||||
Room:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
description: Unique room identifier
|
||||
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
readOnly: true
|
||||
description: Full URL to access the room
|
||||
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
|
||||
telephony:
|
||||
type: object
|
||||
readOnly: true
|
||||
description: Telephony dial-in information (if enabled)
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether telephony is available
|
||||
example: true
|
||||
pin_code:
|
||||
type: string
|
||||
description: PIN code for dial-in access
|
||||
example: "123456"
|
||||
phone_number:
|
||||
type: string
|
||||
description: Phone number to dial
|
||||
example: "+1-555-0100"
|
||||
default_country:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
description: OAuth2-compliant error response
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human-readable error description
|
||||
example: "Invalid credentials"
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: string
|
||||
description: Error message
|
||||
example: "Invalid token."
|
||||
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
field_name:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of validation errors for this field
|
||||
example: ["This field is required."]
|
||||
|
||||
responses:
|
||||
UnauthorizedError:
|
||||
description: The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
invalidToken:
|
||||
summary: Invalid token
|
||||
value:
|
||||
error: "Invalid token."
|
||||
|
||||
ForbiddenError:
|
||||
description: Insufficient scope for this operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
insufficientScope:
|
||||
summary: Missing required scope
|
||||
value:
|
||||
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
|
||||
|
||||
RoomNotFoundError:
|
||||
description: Room not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
roomNotFound:
|
||||
summary: Room does not exist
|
||||
value:
|
||||
detail: "Not found."
|
||||
|
||||
BadRequestError:
|
||||
description: Invalid request data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
examples:
|
||||
validationError:
|
||||
summary: Field validation failed
|
||||
value:
|
||||
scope: ["Invalid email address."]
|
||||
@@ -32,6 +32,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||
|
||||
OIDC_RP_CLIENT_ID=meet
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
OIDC_RS_CLIENT_ID=meet
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
|
||||
@@ -15,7 +15,7 @@ WHISPERX_DEFAULT_LANGUAGE="fr"
|
||||
|
||||
LLM_BASE_URL="https://configure-your-url.com"
|
||||
LLM_API_KEY="dev-apikey"
|
||||
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
|
||||
LLM_MODEL="albert-large"
|
||||
|
||||
WEBHOOK_API_TOKEN="secret"
|
||||
WEBHOOK_URL="https://configure-your-url.com"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=${MEET_HOST}
|
||||
DJANGO_SECRET_KEY=<generate a secret key>
|
||||
DJANGO_SETTINGS_MODULE=meet.settings
|
||||
DJANGO_CONFIGURATION=Production
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Meet settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST=<smtp host>
|
||||
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||
DJANGO_EMAIL_PORT=<smtp port>
|
||||
DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="https://${MEET_HOST}"
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
|
||||
|
||||
OIDC_RP_CLIENT_ID=<client_id>
|
||||
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
LOGIN_REDIRECT_URL=https://${MEET_HOST}
|
||||
LOGIN_REDIRECT_URL_FAILURE=https://${MEET_HOST}
|
||||
LOGOUT_REDIRECT_URL=https://${MEET_HOST}
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${MEET_HOST}"]
|
||||
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=<generate a secret key>
|
||||
LIVEKIT_API_KEY=meet
|
||||
LIVEKIT_API_URL=https://${LIVEKIT_HOST}
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
@@ -0,0 +1,7 @@
|
||||
MEET_HOST=meet.domain.tld
|
||||
KEYCLOAK_HOST=id.domain.tld
|
||||
LIVEKIT_HOST=livekit.domain.tld
|
||||
BACKEND_INTERNAL_HOST=backend
|
||||
FRONTEND_INTERNAL_HOST=frontend
|
||||
LIVEKIT_INTERNAL_HOST=livekit
|
||||
REALM_NAME=meet
|
||||
@@ -0,0 +1,13 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=keycloak
|
||||
POSTGRES_PASSWORD=<generate postgres password>
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# Keycloak postgresql configuration
|
||||
KC_DB=postgres
|
||||
KC_DB_SCHEMA=public
|
||||
KC_DB_URL_HOST=postgresql
|
||||
KC_DB_NAME=${POSTGRES_DB}
|
||||
KC_DB_USER=${POSTGRES_USER}
|
||||
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Keycloak admin user
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||
|
||||
# Keycloak configuration
|
||||
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
|
||||
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
|
||||
@@ -0,0 +1,11 @@
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=meet
|
||||
DB_USER=meet
|
||||
DB_PASSWORD=<generate a secure password>
|
||||
DB_PORT=5432
|
||||
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=meet
|
||||
POSTGRES_USER=meet
|
||||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
@@ -9,6 +9,12 @@
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"]
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -29,15 +29,72 @@ logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
|
||||
# Default Deepgram STT configuration
|
||||
DEEPGRAM_STT_DEFAULTS = {
|
||||
"model": "nova-3",
|
||||
"language": "multi",
|
||||
}
|
||||
|
||||
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
# Use language="multi" instead for automatic multilingual support
|
||||
DEEPGRAM_STT_SUPPORTED_PARAMS = {
|
||||
"model",
|
||||
"language",
|
||||
}
|
||||
|
||||
|
||||
def _build_deepgram_stt_kwargs():
|
||||
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
|
||||
|
||||
Only parameters supported by LiveKit's deepgram.STT() are included.
|
||||
Unsupported parameters are logged as warnings.
|
||||
"""
|
||||
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
|
||||
|
||||
# Scan environment variables for DEEPGRAM_STT_* pattern
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("DEEPGRAM_STT_"):
|
||||
# Extract parameter name and convert to lowercase
|
||||
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
|
||||
|
||||
# Check if parameter is supported by LiveKit plugin
|
||||
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
|
||||
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
|
||||
logger.warning(
|
||||
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
|
||||
f"Supported parameters: {supported}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Parse value type
|
||||
value_lower = value.lower()
|
||||
if value_lower in ("true", "false"):
|
||||
# Boolean values
|
||||
stt_kwargs[param_name] = value_lower == "true"
|
||||
elif value.isdigit():
|
||||
# Integer values
|
||||
stt_kwargs[param_name] = int(value)
|
||||
else:
|
||||
# String values
|
||||
stt_kwargs[param_name] = value
|
||||
|
||||
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
|
||||
return stt_kwargs
|
||||
|
||||
|
||||
class Transcriber(Agent):
|
||||
"""Create a transcription agent for a specific participant."""
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
"""Init transcription agent."""
|
||||
# Build STT configuration from environment variables
|
||||
stt_kwargs = _build_deepgram_stt_kwargs()
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
stt=deepgram.STT(),
|
||||
stt=deepgram.STT(**stt_kwargs),
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "0.1.40"
|
||||
version = "0.1.42"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.2.6",
|
||||
"livekit-plugins-deepgram==1.2.6",
|
||||
"livekit-plugins-silero==1.2.6",
|
||||
"python-dotenv==1.1.1"
|
||||
"livekit-agents==1.2.18",
|
||||
"livekit-plugins-deepgram==1.2.18",
|
||||
"livekit-plugins-silero==1.2.18",
|
||||
"python-dotenv==1.2.1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.12.0",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.recording.event import notification
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@@ -109,10 +111,27 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = ["name", "slug", "access_level", "created_at"]
|
||||
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_owner(self, obj):
|
||||
"""Return the owner of the room for display in the admin list."""
|
||||
|
||||
owners = [
|
||||
access
|
||||
for access in obj.accesses.all()
|
||||
if access.role == models.RoleChoices.OWNER
|
||||
]
|
||||
|
||||
if not owners:
|
||||
return _("No owner")
|
||||
|
||||
if len(owners) > 1:
|
||||
return _("Multiple owners")
|
||||
|
||||
return str(owners[0].user)
|
||||
|
||||
|
||||
class RecordingAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for recording accesses."""
|
||||
@@ -121,15 +140,76 @@ class RecordingAccessInline(admin.TabularInline):
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.action(description=_("Resend notification to external service"))
|
||||
def resend_notification(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Resend notification to external service for selected recordings."""
|
||||
|
||||
notification_service = notification.NotificationService()
|
||||
processed = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for recording in queryset:
|
||||
if recording.is_expired:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
success = notification_service.notify_external_services(recording)
|
||||
|
||||
if success:
|
||||
processed += 1
|
||||
else:
|
||||
failed += 1
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Failed to notify for recording %(id)s") % {"id": recording.id},
|
||||
level=messages.ERROR,
|
||||
)
|
||||
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
|
||||
failed += 1
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Failed to notify for recording %(id)s: %(error)s")
|
||||
% {"id": recording.id, "error": str(e)},
|
||||
level=messages.ERROR,
|
||||
)
|
||||
|
||||
if processed > 0:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Successfully sent notifications for %(count)s recording(s).")
|
||||
% {"count": processed},
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
if skipped > 0:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Skipped %(count)s expired recording(s).") % {"count": skipped},
|
||||
level=messages.WARNING,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Recording)
|
||||
class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
"mode",
|
||||
"room",
|
||||
"get_owner",
|
||||
"created_at",
|
||||
"worker_id",
|
||||
)
|
||||
list_filter = ["status", "room", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
actions = [resend_notification]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
|
||||
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
raise exceptions.AuthenticationFailed("Token missing user identity")
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(id=user_id)
|
||||
user = UserModel.objects.get(sub=user_id)
|
||||
except UserModel.DoesNotExist:
|
||||
user = AnonymousUser()
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import jwt
|
||||
import jwt as pyJwt
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
User = get_user_model()
|
||||
@@ -25,9 +27,11 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
Returns:
|
||||
Tuple of (user, payload) if authentication successful, None otherwise
|
||||
"""
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
# Defer to next authentication backend
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
@@ -45,6 +49,8 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate_credentials(self, token):
|
||||
"""Validate JWT token and return authenticated user.
|
||||
|
||||
If token is invalid, defer to next authentication backend.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
@@ -52,29 +58,29 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
Tuple of (user, payload)
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If token is invalid, expired, or user not found
|
||||
AuthenticationFailed: If token is expired, or user not found
|
||||
"""
|
||||
# Decode and validate JWT
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
payload = pyJwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
except pyJwt.ExpiredSignatureError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except jwt.InvalidIssuerError as e:
|
||||
except pyJwt.InvalidIssuerError as e:
|
||||
logger.warning("Invalid JWT issuer: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt.InvalidAudienceError as e:
|
||||
except pyJwt.InvalidAudienceError as e:
|
||||
logger.warning("Invalid JWT audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning("Invalid JWT token: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except pyJwt.InvalidTokenError:
|
||||
# Invalid JWT token - defer to next authentication backend
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
client_id = payload.get("client_id")
|
||||
@@ -107,3 +113,55 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
|
||||
|
||||
class ResourceServerBackend(LaSuiteBackend):
|
||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Get or create user from OIDC token claims.
|
||||
|
||||
Despite the LaSuiteBackend's method name suggesting "get_or_create",
|
||||
its implementation only performs a GET operation.
|
||||
Create new user from the sub claim.
|
||||
|
||||
Args:
|
||||
access_token: The access token string
|
||||
id_token: The ID token string (unused)
|
||||
payload: Token payload dict (unused)
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Raises:
|
||||
SuspiciousOperation: If user info validation fails
|
||||
"""
|
||||
|
||||
sub = payload.get("sub")
|
||||
|
||||
if sub is None:
|
||||
message = "User info contained no recognizable user identification"
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
user = self.get_user(access_token, id_token, payload)
|
||||
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
"""Create new user from subject claim.
|
||||
|
||||
Args:
|
||||
sub: Subject identifier from token
|
||||
|
||||
Returns:
|
||||
Newly created User instance
|
||||
"""
|
||||
user = self.UserModel(sub=sub)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
@@ -149,7 +150,10 @@ class RoomViewSet(
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [authentication.ApplicationJWTAuthentication]
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||
]
|
||||
|
||||
@@ -158,9 +158,9 @@ class NotificationService:
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
|
||||
@@ -345,7 +345,9 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
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):
|
||||
# Note: .save() triggers uniqueness validation queries for unique fields,
|
||||
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ from django.conf import settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import ApplicationScope, RoleChoices, Room
|
||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -90,13 +91,29 @@ def test_api_rooms_list_with_expired_token(settings):
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_invalid_token():
|
||||
"""Listing rooms with invalid token should return 401."""
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_token(settings):
|
||||
"""Listing rooms with invalid token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_list_missing_scope(settings):
|
||||
@@ -332,3 +349,219 @@ def test_api_rooms_token_missing_client_id(settings):
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token claims." in str(response.data)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_creates_user_on_first_authentication(settings):
|
||||
"""New user should be created during first authentication.
|
||||
|
||||
Verifies that the ResourceServerBackend.get_or_create_user() creates a user
|
||||
in the database when authenticating with a token from an unknown subject (sub).
|
||||
This tests the user creation workflow during the OIDC introspection process.
|
||||
"""
|
||||
|
||||
with pytest.raises(
|
||||
User.DoesNotExist,
|
||||
match="User matching query does not exist.",
|
||||
):
|
||||
User.objects.get(sub="very-specific-sub")
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 0
|
||||
|
||||
db_user = User.objects.get(sub="very-specific-sub")
|
||||
assert db_user is not None
|
||||
assert db_user.email is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings):
|
||||
"""Verify that ResourceServerBackend respects the user auto-creation setting.
|
||||
|
||||
This ensures that the OIDC introspection process respects the configuration flag
|
||||
that controls whether new users should be automatically provisioned during
|
||||
authentication, preventing unwanted user proliferation when auto-creation is
|
||||
explicitly disabled.
|
||||
"""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
with pytest.raises(
|
||||
User.DoesNotExist,
|
||||
match="User matching query does not exist.",
|
||||
):
|
||||
User.objects.get(sub="very-specific-sub")
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_successful(settings):
|
||||
"""Authenticated requests should be processed and user-specific data is returned.
|
||||
|
||||
Verifies that once a user is authenticated via OIDC token introspection,
|
||||
the API correctly identifies the user and returns only data accessible to that user
|
||||
(e.g., rooms with appropriate access levels).
|
||||
"""
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
|
||||
other_user = UserFactory()
|
||||
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room_user_accesses = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||
)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {
|
||||
str(room_user_accesses.id),
|
||||
}
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
"""Requests should be denied when the token lacks required scopes.
|
||||
|
||||
Verifies that the ResourceServerBackend validates token scopes during introspection
|
||||
and returns 403 Forbidden when the token is missing required scopes for the endpoint.
|
||||
"""
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet", # missing rooms:list scope
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,121 +17,153 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Informationen"
|
||||
|
||||
#: core/admin.py:39
|
||||
#: core/admin.py:42
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:51
|
||||
#: core/admin.py:54
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/admin.py:143
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||
|
||||
#: core/admin.py:166
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||
|
||||
#: core/admin.py:174
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||
|
||||
#: core/admin.py:190
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:294
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:34
|
||||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:35
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:33
|
||||
#: core/models.py:36
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:52
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:53
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:54
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:86
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:93
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:94
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:92
|
||||
#: core/models.py:95
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:107
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:121
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -139,11 +171,11 @@ msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:147
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
@@ -151,55 +183,55 @@ msgstr ""
|
||||
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
|
||||
"erlaubt."
|
||||
|
||||
#: core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -207,66 +239,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:208
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:209
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:268
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:266
|
||||
#: core/models.py:269
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:323
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:321
|
||||
#: core/models.py:324
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:327
|
||||
#: core/models.py:330
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:386
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:384
|
||||
#: core/models.py:387
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:391
|
||||
#: core/models.py:394
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:392
|
||||
#: core/models.py:395
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
#: core/models.py:401 core/models.py:555
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:399
|
||||
#: core/models.py:402
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:563
|
||||
#: core/models.py:566
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:565
|
||||
#: core/models.py:568
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -275,42 +307,102 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:573
|
||||
#: core/models.py:576
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:577
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:583
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:584
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:692
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:690
|
||||
#: core/models.py:693
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:696
|
||||
#: core/models.py:699
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:702
|
||||
#: core/models.py:705
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:708
|
||||
#: core/models.py:711
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:728
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:729
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:730
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:731
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:745
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:746
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:756
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:767
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:768
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:791
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:794
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:795
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:807
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
@@ -401,7 +493,8 @@ msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
|
||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
|
||||
"Organisatoren können sie herunterladen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
@@ -438,18 +531,18 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:167
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:168
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:169
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:170
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,119 +17,151 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Personal info"
|
||||
|
||||
#: core/admin.py:39
|
||||
#: core/admin.py:42
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:51
|
||||
#: core/admin.py:54
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/admin.py:143
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Resend notification to external service"
|
||||
|
||||
#: core/admin.py:166
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Failed to notify for recording %(id)s"
|
||||
|
||||
#: core/admin.py:174
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:294
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:34
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:35
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:33
|
||||
#: core/models.py:36
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:52
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:53
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:54
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:86
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:93
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:94
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:92
|
||||
#: core/models.py:95
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:107
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:121
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -137,11 +169,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:147
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
@@ -149,55 +181,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -205,63 +237,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:208
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:209
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:268
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:266
|
||||
#: core/models.py:269
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:323
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:321
|
||||
#: core/models.py:324
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:327
|
||||
#: core/models.py:330
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:386
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:384
|
||||
#: core/models.py:387
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:391
|
||||
#: core/models.py:394
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:392
|
||||
#: core/models.py:395
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
#: core/models.py:401 core/models.py:555
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:399
|
||||
#: core/models.py:402
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:563
|
||||
#: core/models.py:566
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:565
|
||||
#: core/models.py:568
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -269,42 +301,106 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:573
|
||||
#: core/models.py:576
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:577
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:583
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:584
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:692
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:690
|
||||
#: core/models.py:693
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:696
|
||||
#: core/models.py:699
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:702
|
||||
#: core/models.py:705
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:708
|
||||
#: core/models.py:711
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:728
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:729
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:730
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:731
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:745
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:746
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:756
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:767
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:768
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:791
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:794
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:795
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:807
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
@@ -433,18 +529,18 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:167
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:168
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:169
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:170
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,123 +17,155 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:39
|
||||
#: core/admin.py:42
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:51
|
||||
#: core/admin.py:54
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/admin.py:143
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Renvoyer la notification au service externe"
|
||||
|
||||
#: core/admin.py:166
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||
|
||||
#: core/admin.py:174
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||
|
||||
#: core/admin.py:294
|
||||
msgid "No scopes"
|
||||
msgstr "Aucun scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:34
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:35
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:33
|
||||
#: core/models.py:36
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:52
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:53
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:54
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:86
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:93
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:94
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:92
|
||||
#: core/models.py:95
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:107
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:121
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -141,11 +173,11 @@ msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:147
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
@@ -153,55 +185,55 @@ msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -209,65 +241,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:208
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:209
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:268
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:266
|
||||
#: core/models.py:269
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:323
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:321
|
||||
#: core/models.py:324
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:327
|
||||
#: core/models.py:330
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:386
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:384
|
||||
#: core/models.py:387
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:391
|
||||
#: core/models.py:394
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:392
|
||||
#: core/models.py:395
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
#: core/models.py:401 core/models.py:555
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:399
|
||||
#: core/models.py:402
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:563
|
||||
#: core/models.py:566
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:565
|
||||
#: core/models.py:568
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -275,42 +307,102 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:573
|
||||
#: core/models.py:576
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:577
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:583
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:584
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:692
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:690
|
||||
#: core/models.py:693
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:696
|
||||
#: core/models.py:699
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:702
|
||||
#: core/models.py:705
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:708
|
||||
#: core/models.py:711
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:728
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:729
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:730
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:731
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:745
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:746
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:756
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un nouveau secret."
|
||||
|
||||
#: core/models.py:767
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:768
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:791
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:794
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:795
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:807
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
@@ -401,7 +493,8 @@ msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
|
||||
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
|
||||
"les organisateurs peuvent le télécharger."
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
@@ -438,18 +531,18 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:167
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:168
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:169
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:170
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,120 +17,152 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:29
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: core/admin.py:39
|
||||
#: core/admin.py:42
|
||||
msgid "Permissions"
|
||||
msgstr "Rechten"
|
||||
|
||||
#: core/admin.py:51
|
||||
#: core/admin.py:54
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/admin.py:143
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||
|
||||
#: core/admin.py:166
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt"
|
||||
|
||||
#: core/admin.py:174
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||
|
||||
#: core/admin.py:294
|
||||
msgid "No scopes"
|
||||
msgstr "Geen scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:34
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:35
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:33
|
||||
#: core/models.py:36
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:52
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:53
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:54
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:86
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:93
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:94
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:92
|
||||
#: core/models.py:95
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:107
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:121
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -138,66 +170,66 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:147
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
|
||||
|
||||
#: core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:162
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -205,64 +237,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:208
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:209
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:268
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:266
|
||||
#: core/models.py:269
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:323
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:321
|
||||
#: core/models.py:324
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:327
|
||||
#: core/models.py:330
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:386
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:384
|
||||
#: core/models.py:387
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:391
|
||||
#: core/models.py:394
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:392
|
||||
#: core/models.py:395
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
#: core/models.py:401 core/models.py:555
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:399
|
||||
#: core/models.py:402
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:563
|
||||
#: core/models.py:566
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:565
|
||||
#: core/models.py:568
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -270,42 +302,102 @@ msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:573
|
||||
#: core/models.py:576
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:577
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:583
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:584
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:692
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:690
|
||||
#: core/models.py:693
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:696
|
||||
#: core/models.py:699
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:702
|
||||
#: core/models.py:705
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:708
|
||||
#: core/models.py:711
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:728
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:729
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:730
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:731
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:745
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:746
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:756
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:767
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:768
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:791
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:794
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:795
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:807
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
@@ -396,7 +488,8 @@ msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
|
||||
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
|
||||
"organisatoren kunnen deze downloaden."
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
@@ -433,18 +526,18 @@ msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:167
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:168
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:169
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:170
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -10,6 +10,8 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
@@ -326,6 +328,11 @@ class Base(Configuration):
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
|
||||
),
|
||||
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
|
||||
None,
|
||||
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
@@ -399,6 +406,10 @@ class Base(Configuration):
|
||||
default=False,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
)
|
||||
OIDC_TIMEOUT = values.IntegerValue(
|
||||
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
OIDC_RP_SIGN_ALGO = values.Value(
|
||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||
)
|
||||
@@ -422,12 +433,16 @@ class Base(Configuration):
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
||||
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
||||
)
|
||||
@@ -488,6 +503,42 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC Resource Server Backend
|
||||
OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend"
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
"meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_CLIENT_SECRET = SecretFileValue(
|
||||
None,
|
||||
environ_name="OIDC_RS_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
default="A256GCM",
|
||||
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
default=["lasuite_meet"],
|
||||
environ_name="OIDC_RS_SCOPES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_PRIVATE_KEY_STR = SecretFileValue(
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||
|
||||
@@ -15,6 +15,7 @@ from drf_spectacular.views import (
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
path("", include("lasuite.oidc_resource_server.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
+31
-31
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.40"
|
||||
version = "0.1.42"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,38 +25,38 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.38.42",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"boto3==1.40.69",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-lasuite[all]==0.0.10",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.0.0",
|
||||
"django-lasuite[all]==0.0.19",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.7",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
"django==5.2.9",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.24.0",
|
||||
"markdown==3.8.2",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"jsonschema==4.25.1",
|
||||
"markdown==3.10",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk==2.30.0",
|
||||
"whitenoise==6.9.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.43.0",
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.3",
|
||||
"aiohttp==3.12.13",
|
||||
"livekit-api==1.0.7",
|
||||
"aiohttp==3.13.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -68,21 +68,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2025.6.1",
|
||||
"freezegun==1.5.2",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.3.0",
|
||||
"pyfakefs==5.9.1",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.2.1",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==8.4.1",
|
||||
"pytest==9.0.0",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.7.0",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.12.0",
|
||||
"types-requests==2.32.4.20250611",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.4",
|
||||
"types-requests==2.32.4.20250913",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -38,7 +38,12 @@ RUN npm run build
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
|
||||
RUN apk update && apk upgrade libssl3 \
|
||||
libcrypto3 \
|
||||
libxml2>=2.12.7-r2 \
|
||||
libxslt>=1.1.39-r2 \
|
||||
libexpat>=2.7.2-r0 \
|
||||
libpng>=1.6.53-r0
|
||||
|
||||
USER nginx
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ApiConfig {
|
||||
custom_css_url?: string
|
||||
use_french_gov_footer?: boolean
|
||||
use_proconnect_button?: boolean
|
||||
idle_disconnect_warning_delay?: number
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
|
||||
@@ -5,6 +5,6 @@ export const authUrl = ({
|
||||
returnTo = window.location.href,
|
||||
} = {}) => {
|
||||
return apiUrl(
|
||||
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
|
||||
`/authenticate/?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,11 @@ export const LaterMeetingDialog = ({
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,13 +107,18 @@ export const LaterMeetingDialog = ({
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
@@ -131,7 +140,11 @@ export const LaterMeetingDialog = ({
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
@@ -139,6 +152,7 @@ export const LaterMeetingDialog = ({
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('copy')
|
||||
@@ -173,6 +187,7 @@ export const LaterMeetingDialog = ({
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
|
||||
@@ -115,7 +115,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -147,13 +151,18 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
|
||||
@@ -71,7 +71,6 @@ export const FullScreenShareWarning = ({
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
@@ -81,7 +80,6 @@ export const FullScreenShareWarning = ({
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: 'rgba(22, 22, 34, 0.9)',
|
||||
@@ -115,6 +113,7 @@ export const FullScreenShareWarning = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '1rem',
|
||||
zIndex: '1000',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -78,12 +78,20 @@ export const Info = () => {
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
|
||||
<RiCheckLine
|
||||
size={24}
|
||||
style={{ marginRight: '6px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('roomInformation.button.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
|
||||
<RiFileCopyLine
|
||||
size={24}
|
||||
style={{ marginRight: '6px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('roomInformation.button.copy')}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||
setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||
const interval = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1000) {
|
||||
clearInterval(interval)
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
navigateTo('feedback', { duplicateIdentity: false })
|
||||
return 0
|
||||
}
|
||||
return prev - 1000
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||
|
||||
const minutes = Math.floor(timeRemaining / 1000 / 60)
|
||||
const seconds = (timeRemaining / 1000) % 60
|
||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||
role="alertdialog"
|
||||
type="alert"
|
||||
aria-label={t('title')}
|
||||
onClose={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
}}
|
||||
>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
width: '50px',
|
||||
backgroundColor: 'blue.100',
|
||||
borderRadius: '25px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontWeight: '500',
|
||||
color: 'blue.800',
|
||||
margin: 'auto',
|
||||
})}
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
<H lvl={2} centered>
|
||||
{t('title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('body', {
|
||||
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
|
||||
language: i18n.language,
|
||||
}),
|
||||
})}
|
||||
</P>
|
||||
<P>{t('settings')}</P>
|
||||
<HStack marginTop="2rem">
|
||||
<Button
|
||||
onPress={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
navigateTo('feedback', { duplicateIdentity: false })
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('leaveButton')}
|
||||
</Button>
|
||||
<Button onPress={close} size="sm" variant="primary">
|
||||
{t('stayButton')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,72 @@
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
useRemoteParticipants,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import posthog from 'posthog-js'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const useConnectionObserver = () => {
|
||||
const room = useRoomContext()
|
||||
const connectionStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
|
||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantConnected,
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear existing timer on dependency change
|
||||
if (idleDisconnectModalTimeoutRef.current) {
|
||||
clearTimeout(idleDisconnectModalTimeoutRef.current)
|
||||
idleDisconnectModalTimeoutRef.current = null
|
||||
}
|
||||
|
||||
const isEnabled = userPreferencesSnap.is_idle_disconnect_modal_enabled
|
||||
const delay = data?.idle_disconnect_warning_delay
|
||||
|
||||
// Disabled or invalid delay: ensure modal is closed
|
||||
if (!isEnabled || !delay) {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
return
|
||||
}
|
||||
|
||||
if (remoteParticipants.length === 0) {
|
||||
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = true
|
||||
}, delay)
|
||||
} else {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (idleDisconnectModalTimeoutRef.current) {
|
||||
clearTimeout(idleDisconnectModalTimeoutRef.current)
|
||||
idleDisconnectModalTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
remoteParticipants.length,
|
||||
data?.idle_disconnect_warning_delay,
|
||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -191,6 +192,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Field, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
@@ -9,6 +11,8 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('language.heading')}</H>
|
||||
@@ -21,6 +25,20 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
|
||||
i18n.changeLanguage(lang as string)
|
||||
}}
|
||||
/>
|
||||
<H lvl={2}>{t('preferences.title')}</H>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('preferences.idleDisconnectModal.label')}
|
||||
description={t('preferences.idleDisconnectModal.description')}
|
||||
isSelected={userPreferencesSnap.is_idle_disconnect_modal_enabled}
|
||||
onChange={(value) =>
|
||||
(userPreferencesStore.is_idle_disconnect_modal_enabled = value)
|
||||
}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { H, Switch } from '@/primitives'
|
||||
import { Field, H } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
@@ -23,15 +23,19 @@ export const NotificationsTab = ({ id }: NotificationsTabProps) => {
|
||||
{Array.from(notificationsSnap.soundNotifications).map(
|
||||
([key, value]) => (
|
||||
<li key={key}>
|
||||
<Switch
|
||||
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
|
||||
<Field
|
||||
type="switch"
|
||||
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}.label`)}"`}
|
||||
label={t(`items.${key}.label`)}
|
||||
isSelected={value}
|
||||
onChange={(v) => {
|
||||
notificationsStore.soundNotifications.set(key, v)
|
||||
}}
|
||||
>
|
||||
{t(`items.${key}`)}
|
||||
</Switch>
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Neues Fenster"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Bist du noch da?",
|
||||
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
|
||||
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
|
||||
"stayButton": "Gespräch fortsetzen",
|
||||
"leaveButton": "Jetzt verlassen"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Mikrofon",
|
||||
"camera": "Kamera",
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"authentication": "Authentifizierung",
|
||||
"nameError": "Ihr Name darf nicht leer sein"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Einstellungen",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Anrufe ohne Teilnehmer verlassen",
|
||||
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Mikrofon",
|
||||
@@ -69,9 +76,15 @@
|
||||
"enable": "Aktivieren"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": "Teilnehmer beigetreten",
|
||||
"handRaised": "Hand gehoben",
|
||||
"messageReceived": "Nachricht erhalten"
|
||||
"participantJoined": {
|
||||
"label": "Teilnehmer beigetreten"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Hand gehoben"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Nachricht erhalten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "New window"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Are you still there?",
|
||||
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
|
||||
"settings": "To stop seeing this message, go to Settings > General.",
|
||||
"stayButton": "Continue the call",
|
||||
"leaveButton": "Leave now"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"authentication": "Authentication",
|
||||
"nameError": "Your name cannot be empty"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Leave calls with no participants",
|
||||
"description": "Automatically leaves a call after a few minutes if no other participant joins"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Microphone",
|
||||
@@ -69,9 +76,15 @@
|
||||
"enable": "Enable"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": "Participant joined",
|
||||
"handRaised": "Hand raised",
|
||||
"messageReceived": "Message received"
|
||||
"participantJoined": {
|
||||
"label": "Participant joined"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Hand raised"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Message received"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Nouvelle fenêtre"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Êtes-vous toujours là ?",
|
||||
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
|
||||
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
|
||||
"stayButton": "Poursuivre l'appel",
|
||||
"leaveButton": "Quitter maintenant"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"authentication": "Authentification",
|
||||
"nameError": "Votre Nom ne peut pas être vide"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Préférences",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Quitter les appels sans autre participant",
|
||||
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Micro",
|
||||
@@ -69,9 +76,15 @@
|
||||
"enable": "Activer"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": "Un nouveau participant",
|
||||
"handRaised": "Une main levée",
|
||||
"messageReceived": "Un message reçu"
|
||||
"participantJoined": {
|
||||
"label": "Un nouveau participant"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Une main levée"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Un message reçu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"newTab": "Nieuw venster"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Ben je er nog?",
|
||||
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
|
||||
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
|
||||
"stayButton": "Gesprek voortzetten",
|
||||
"leaveButton": "Nu verlaten"
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microfoon",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"authentication": "Authenticatie",
|
||||
"nameError": "Uw naam mag niet leeg zijn"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Voorkeuren",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Verlaat oproepen zonder deelnemers",
|
||||
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Microfoon",
|
||||
@@ -69,9 +76,15 @@
|
||||
"enable": "Inschakelen"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": "Deelnemer is toegevoegd",
|
||||
"handRaised": "Hand opgestoken",
|
||||
"messageReceived": "Bericht ontvangen"
|
||||
"participantJoined": {
|
||||
"label": "Deelnemer is toegevoegd"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Hand opgestoken"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Bericht ontvangen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -28,6 +28,10 @@ const box = cva({
|
||||
width: '30rem',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
alert: {
|
||||
width: '24rem',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
light: {
|
||||
|
||||
@@ -80,7 +80,7 @@ export type DialogProps = RACDialogProps & {
|
||||
* after user interaction
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
type?: 'flex'
|
||||
type?: 'flex' | 'alert'
|
||||
innerRef?: MutableRefObject<HTMLDivElement | null>
|
||||
size?: 'full' | 'large'
|
||||
}
|
||||
@@ -96,7 +96,12 @@ export const Dialog = ({
|
||||
...dialogProps
|
||||
}: DialogProps) => {
|
||||
const isAlert = dialogProps['role'] === 'alertdialog'
|
||||
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
|
||||
const boxType =
|
||||
dialogProps['type'] === 'alert'
|
||||
? 'alert'
|
||||
: dialogProps['type'] !== 'flex'
|
||||
? 'dialog'
|
||||
: undefined
|
||||
return (
|
||||
<StyledModalOverlay
|
||||
isKeyboardDismissDisabled={isAlert}
|
||||
|
||||
@@ -259,6 +259,7 @@ export const Field = <T extends object>({
|
||||
}
|
||||
|
||||
if (type === 'switch') {
|
||||
const SWITCH_COMPONENT_WIDTH = '41px'
|
||||
return (
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<div
|
||||
@@ -273,10 +274,11 @@ export const Field = <T extends object>({
|
||||
{description && (
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
wrap={'balance'}
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '0.5rem',
|
||||
marginRight: SWITCH_COMPONENT_WIDTH,
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isIdleDisconnectModalOpen: boolean
|
||||
}
|
||||
|
||||
export const connectionObserverStore = proxy<State>({
|
||||
isIdleDisconnectModalOpen: false,
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { subscribe } from 'valtio/index'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
|
||||
type State = {
|
||||
is_idle_disconnect_modal_enabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
is_idle_disconnect_modal_enabled: true,
|
||||
}
|
||||
|
||||
function getUserPreferencesState(): State {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored)
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[UserPreferencesStore] Failed to parse stored settings:',
|
||||
error
|
||||
)
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const userPreferencesStore = proxy<State>(getUserPreferencesState())
|
||||
|
||||
subscribe(userPreferencesStore, () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.USER_PREFERENCES,
|
||||
JSON.stringify(userPreferencesStore)
|
||||
)
|
||||
})
|
||||
@@ -3,4 +3,5 @@
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
NOTIFICATIONS: 'app_notification_settings',
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
} as const
|
||||
|
||||
@@ -57,6 +57,7 @@ backend:
|
||||
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.13
|
||||
version: 0.0.15
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- /* Normalize extraObjects to a list, easier to loop over */ -}}
|
||||
{{- $extraObjects := .Values.extraManifests | default (list) -}}
|
||||
|
||||
{{- if kindIs "map" $extraObjects -}}
|
||||
{{- $extraObjects = values $extraObjects -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- range $extraObjects }}
|
||||
---
|
||||
{{- if kindIs "map" . }}
|
||||
{{- tpl (toYaml .) $ | nindent 0 }}
|
||||
{{- else if kindIs "string" . }}
|
||||
{{- tpl . $ | nindent 0 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -428,6 +428,13 @@ posthog:
|
||||
## @section summary
|
||||
|
||||
summary:
|
||||
## @param summary.image.repository Repository to use to pull meet's summary container image
|
||||
## @param summary.image.tag meet's summary container tag
|
||||
## @param summary.image.pullPolicy summary container image pull policy
|
||||
image:
|
||||
repository: lasuite/meet-summary
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
## @param summary.dpAnnotations Annotations to add to the summary Deployment
|
||||
dpAnnotations: {}
|
||||
@@ -527,11 +534,27 @@ summary:
|
||||
## @section celeryTranscribe
|
||||
|
||||
celeryTranscribe:
|
||||
## @param celeryTranscribe.image.repository Repository to use to pull meet's celeryTranscribe container image
|
||||
## @param celeryTranscribe.image.tag meet's celeryTranscribe container tag
|
||||
## @param celeryTranscribe.image.pullPolicy celeryTranscribe container image pull policy
|
||||
image:
|
||||
repository: lasuite/meet-summary
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
## @param celeryTranscribe.dpAnnotations Annotations to add to the celeryTranscribe Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param celeryTranscribe.command Override the celeryTranscribe container command
|
||||
command: []
|
||||
command:
|
||||
- "celery"
|
||||
- "-A"
|
||||
- "summary.core.celery_worker"
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
- "-Q"
|
||||
- "transcribe-queue"
|
||||
|
||||
## @param celeryTranscribe.args Override the celeryTranscribe container args
|
||||
args: []
|
||||
@@ -620,11 +643,27 @@ celeryTranscribe:
|
||||
## @section celerySummarize
|
||||
|
||||
celerySummarize:
|
||||
## @param celerySummarize.image.repository Repository to use to pull meet's celerySummarize container image
|
||||
## @param celerySummarize.image.tag meet's celerySummarize container tag
|
||||
## @param celerySummarize.image.pullPolicy celerySummarize container image pull policy
|
||||
image:
|
||||
repository: lasuite/meet-summary
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
## @param celerySummarize.dpAnnotations Annotations to add to the celerySummarize Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param celerySummarize.command Override the celerySummarize container command
|
||||
command: []
|
||||
command:
|
||||
- "celery"
|
||||
- "-A"
|
||||
- "summary.core.celery_worker"
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
- "-Q"
|
||||
- "summarize-queue"
|
||||
|
||||
## @param celerySummarize.args Override the celerySummarize container args
|
||||
args: []
|
||||
@@ -749,9 +788,21 @@ agents:
|
||||
## @extra agents.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
|
||||
## @extra agents.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
|
||||
## @extra agents.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
||||
## @extra agents.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
|
||||
## @extra agents.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
|
||||
## @skip agents.envVars
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
# Deepgram Speech-to-Text configuration for real-time streaming
|
||||
# Only 'model' and 'language' parameters are supported by the LiveKit plugin
|
||||
#
|
||||
# DEEPGRAM_STT_MODEL: "nova-3" # Model selection (default)
|
||||
# DEEPGRAM_STT_LANGUAGE: "multi" # Multilingual mode with auto-detection (default)
|
||||
# DEEPGRAM_STT_LANGUAGE: "fr" # Force French only
|
||||
#
|
||||
# Note: Advanced features (diarization, smart_format, punctuate, detect_language)
|
||||
# are NOT supported by the LiveKit Deepgram plugin in streaming mode.
|
||||
# Use language="multi" for automatic multilingual support (10 languages).
|
||||
|
||||
## @param agents.podAnnotations Annotations to add to the agents Pod
|
||||
podAnnotations: {}
|
||||
@@ -783,3 +834,21 @@ agents:
|
||||
## @param agents.pdb.enabled Enable pdb on agents
|
||||
pdb:
|
||||
enabled: false
|
||||
|
||||
## @section Extra Manifests
|
||||
|
||||
## @param extraManifests Extra Kubernetes manifests to deploy
|
||||
## @extra extraManifests[].apiVersion API version of the resource
|
||||
## @extra extraManifests[].kind Kind of the resource (Deployment, Service, ConfigMap, etc.)
|
||||
## @extra extraManifests[].metadata Resource metadata
|
||||
## @extra extraManifests[].spec Resource specification
|
||||
extraManifests: []
|
||||
# - apiVersion: v1
|
||||
# kind: ConfigMap
|
||||
# metadata:
|
||||
# name: my-custom-config
|
||||
# data:
|
||||
# config.json: |
|
||||
# {
|
||||
# "feature": "enabled"
|
||||
# }
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.42",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "0.1.40"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
@@ -9,17 +9,17 @@ dependencies = [
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.5.3",
|
||||
"redis==5.2.1",
|
||||
"minio==7.2.15",
|
||||
"minio==7.2.18",
|
||||
"mutagen==1.47.0",
|
||||
"openai==1.91.0",
|
||||
"posthog==6.0.3",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk[fastapi, celery]==2.30.0",
|
||||
"openai==2.7.1",
|
||||
"posthog==6.9.1",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk[fastapi, celery]==2.43.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.12.0",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -123,7 +123,7 @@ class MetadataManager:
|
||||
logger.error("Invalid number of arguments to enable metadata manager.")
|
||||
return
|
||||
|
||||
filename, email, _, received_at, *_ = task_args
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
initial_metadata = {
|
||||
**initial_metadata,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Celery workers."""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
# ruff: noqa: PLR0913, PLR0915
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import openai
|
||||
import sentry_sdk
|
||||
@@ -22,6 +22,8 @@ from urllib3.util import Retry
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.prompt import (
|
||||
FORMAT_NEXT_STEPS,
|
||||
FORMAT_PLAN,
|
||||
PROMPT_SYSTEM_CLEANING,
|
||||
PROMPT_SYSTEM_NEXT_STEP,
|
||||
PROMPT_SYSTEM_PART,
|
||||
@@ -115,24 +117,53 @@ class LLMService:
|
||||
base_url=settings.llm_base_url, api_key=settings.llm_api_key
|
||||
)
|
||||
|
||||
def call(self, system_prompt: str, user_prompt: str):
|
||||
def call(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_format: Optional[Mapping[str, Any]] = None,
|
||||
):
|
||||
"""Call the LLM service.
|
||||
|
||||
Takes a system prompt and a user prompt, and returns the LLM's response
|
||||
Returns None if the call fails.
|
||||
"""
|
||||
try:
|
||||
response = self._client.chat.completions.create(
|
||||
model=settings.llm_model,
|
||||
messages=[
|
||||
params: dict[str, Any] = {
|
||||
"model": settings.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
)
|
||||
}
|
||||
if response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
|
||||
response = self._client.chat.completions.create(**params)
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.error("LLM call failed: %s", e)
|
||||
raise LLMException("LLM call failed.") from e
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException("LLM call failed: {e}") from e
|
||||
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
def format_segments(transcription_data):
|
||||
@@ -202,11 +233,16 @@ def process_audio_transcribe_summarize_v2(
|
||||
3. Sends the results via webhook
|
||||
|
||||
"""
|
||||
logger.info("Notification received")
|
||||
logger.debug("filename: %s", filename)
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
logger.info("Download recording | Filename: %s", filename)
|
||||
|
||||
minio_client = Minio(
|
||||
settings.aws_s3_endpoint_url,
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
@@ -247,7 +283,9 @@ def process_audio_transcribe_summarize_v2(
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Querying transcription …")
|
||||
logger.info(
|
||||
"Querying transcription for %s seconds of audio …", audio_file.info.length
|
||||
)
|
||||
transcription_start_time = time.time()
|
||||
with open(temp_file_path, "rb") as audio_file:
|
||||
transcription = whisperx_client.audio.transcriptions.create(
|
||||
@@ -255,15 +293,13 @@ def process_audio_transcribe_summarize_v2(
|
||||
file=audio_file,
|
||||
language=settings.whisperx_default_language,
|
||||
)
|
||||
|
||||
transcription_time = round(time.time() - transcription_start_time, 2)
|
||||
metadata_manager.track(
|
||||
task_id,
|
||||
{
|
||||
"transcription_time": round(
|
||||
time.time() - transcription_start_time, 2
|
||||
)
|
||||
},
|
||||
{"transcription_time": transcription_time},
|
||||
)
|
||||
logger.info("Transcription received.")
|
||||
logger.info("Transcription received in %s seconds.", transcription_time)
|
||||
logger.debug("Transcription: \n %s", transcription)
|
||||
finally:
|
||||
if os.path.exists(temp_file_path):
|
||||
@@ -298,8 +334,19 @@ def process_audio_transcribe_summarize_v2(
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
logger.info(
|
||||
"Webhook success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
@@ -313,7 +360,7 @@ def process_audio_transcribe_summarize_v2(
|
||||
queue=settings.summarize_queue,
|
||||
)
|
||||
else:
|
||||
logger.info("Summary generation not enabled for this user.")
|
||||
logger.info("Summary generation not enabled for this user. Skipping.")
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
@@ -359,13 +406,14 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
|
||||
|
||||
logger.info("TLDR generated")
|
||||
|
||||
parts = llm_service.call(PROMPT_SYSTEM_PLAN, transcript)
|
||||
parts = llm_service.call(
|
||||
PROMPT_SYSTEM_PLAN, transcript, response_format=FORMAT_PLAN
|
||||
)
|
||||
logger.info("Plan generated")
|
||||
|
||||
parts = parts.split("\n")
|
||||
parts = [x for x in parts if x.strip() != ""]
|
||||
logger.info("Empty parts removed")
|
||||
|
||||
res = json.loads(parts)
|
||||
parts = res.get("titles", [])
|
||||
logger.info("Parts to summarize: %s", parts)
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
@@ -376,7 +424,12 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
|
||||
|
||||
raw_summary = "\n\n".join(parts_summarized)
|
||||
|
||||
next_steps = llm_service.call(PROMPT_SYSTEM_NEXT_STEP, transcript)
|
||||
next_steps = llm_service.call(
|
||||
PROMPT_SYSTEM_NEXT_STEP, transcript, response_format=FORMAT_NEXT_STEPS
|
||||
)
|
||||
|
||||
next_steps = format_actions(json.loads(next_steps))
|
||||
|
||||
logger.info("Next steps generated")
|
||||
|
||||
cleaned_summary = llm_service.call(PROMPT_SYSTEM_CLEANING, raw_summary)
|
||||
|
||||
@@ -4,12 +4,8 @@ PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (ré
|
||||
### Résumé TL;DR
|
||||
[Résumé concis et structuré]"""
|
||||
|
||||
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et qu’aucun thème ne soit répété. Tu te limiteras à 5 ou 6 sujets maximum.
|
||||
L'introduction, ordre du jour, conclusion, etc. seront rajoutés a posteriori. Tu répondras dans le format suivant sans rien ajouter d'autre:
|
||||
"Titre du sujet 1
|
||||
Titre du sujet 2
|
||||
Titre du sujet 3
|
||||
..."
|
||||
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et qu’aucun thème ne soit répété. Tu te limiteras à 5 ou 6 sujets maximum.
|
||||
L'introduction, ordre du jour, conclusion, etc. seront rajoutés a posteriori. Si il n'y a pas de sujets clairs, réponds "Général".
|
||||
"""
|
||||
|
||||
PROMPT_SYSTEM_PART = """Tu es un agent dont le rôle est de créer une partie du résumé d'un compte rendu de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript, et le titre du sujet correspondant. Ta tâche est de rédiger un résumé concis de cette partie et uniquement cette partie, en te concentrant uniquement sur les informations essentielles et pertinentes. Le résumé de chaque partie doit tenir en 4 à 6 phrases maximum, sans entrer dans les détails mineurs. Tu répondras dans le format suivant :
|
||||
@@ -23,6 +19,53 @@ Transcript complet :
|
||||
|
||||
PROMPT_SYSTEM_CLEANING = """Tu es un agent dont le rôle est de nettoyer un résumé de compte rendu de réunion. Tu recevras en entrée le résumé brut, potentiellement avec des erreurs de formatage, des incohérences ou des redondances. Ta tâche est de corriger les erreurs de formatage, d'améliorer la clarté et la cohérence du texte, et de t'assurer que le résumé est bien structuré et facile à lire. Ton but principal est de retirer les redondances et les répétitions. Assure la cohérence entre les titres et homogénéise le style d’écriture entre les parties. Supprime les doublons d’informations entre les parties si présents. Si certaines parties sont plus secondaires, tu peux les fusionner ou les réduire en 1 à 2 phrases. Mets en avant les points centraux qui ont fait l’objet de décisions ou d’actions. Tu répondras uniquement avec le résumé sans rien ajouter d'autre"""
|
||||
|
||||
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et de lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite. Les actions doivent suivre ce format strict :
|
||||
### Prochaines étapes
|
||||
- [ ] [Action à effectuer] Assignée à : [Nom], Échéance : [Date si mentionnée]"""
|
||||
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utiliseras un style synthétique, administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et de lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite."""
|
||||
|
||||
FORMAT_NEXT_STEPS = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "actions",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"assignees": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Noms des personnes assignées",
|
||||
},
|
||||
"due_date": {
|
||||
"type": "string",
|
||||
"description": "Date d'échéance si mentionnée (si l'année nest pas précisée, ne pas l'ajouter)",
|
||||
},
|
||||
},
|
||||
"required": ["title", "assignees"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["actions"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
FORMAT_PLAN = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "Titles",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"titles": {"type": "array", "items": {"type": "string"}}},
|
||||
"required": ["titles"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user