mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 808fc7a00f | |||
| c8b4592d88 | |||
| db071f0dcc | |||
| b1b28fe330 | |||
| 98950f43af | |||
| b43f9922b6 | |||
| d49dc72849 | |||
| 82f0251101 | |||
| 2f675c2a3f | |||
| 7105c7cbae | |||
| 376ff8982c | |||
| a4820ae867 | |||
| 2a56cda55c | |||
| 817cf25b37 | |||
| fdaf567f5d | |||
| 0cf5ab1825 | |||
| c9c5d3b452 | |||
| f0b250739c | |||
| 41c9693d10 | |||
| 83cfcacc0e | |||
| e8618099ac | |||
| ac183c9eb9 | |||
| 3dcc93b630 | |||
| 3614b1e803 | |||
| 5e57647b34 | |||
| e271c87a20 | |||
| 40d1f01277 | |||
| 8477296471 | |||
| d200eeb6bd | |||
| 7a51b09664 | |||
| ec22abf82b | |||
| cde4b10794 | |||
| a47f1f92c4 | |||
| 0db36c788b | |||
| a84b76170d | |||
| 998382020d | |||
| 2a12715673 | |||
| 54d4330a97 | |||
| 5dcbe56e56 | |||
| 1a52221ef2 | |||
| 2f3e64b389 | |||
| 2dcaf814e1 | |||
| 583f5b8e70 | |||
| fe8fd36467 | |||
| 0370d9cad0 | |||
| 0da8aa846a | |||
| 70ed99b6c9 | |||
| 1875a394c6 | |||
| 211ba332dc | |||
| 7333c21632 | |||
| 78ebd1a8fd | |||
| 682b69fc11 | |||
| 7a73bf8fc2 | |||
| 1e934957f5 | |||
| 5a7584a3ad | |||
| fb9bf6b08e | |||
| 11b8541005 | |||
| 64607ae8d0 | |||
| c403bbc347 | |||
| 62855fe12c | |||
| f27d451eb6 | |||
| 68792d8019 | |||
| ebb8c14eeb | |||
| 15d43b8d5e | |||
| 1faae96ccd | |||
| 651cc0e5bd | |||
| 0dadd472ff | |||
| be35ee0258 | |||
| bece79f47b | |||
| a499331960 | |||
| d50d167d0a | |||
| e4c7bc0826 | |||
| b5244a5ec0 | |||
| 8cc2cc83c6 | |||
| e591d09b00 | |||
| eeb4dae12d | |||
| 271389d459 | |||
| fe6eefa1f0 | |||
| a276517278 | |||
| 9e9b9015f4 | |||
| 3f0378aada | |||
| 90c88a8bd3 | |||
| 1103902c12 | |||
| 812016d09c | |||
| cadc5c0034 | |||
| b083d837f8 | |||
| db8445f4ab | |||
| 0e5c2be445 | |||
| 0131a65509 | |||
| fb3727e73e | |||
| 3391165e4b | |||
| 0be94aa572 | |||
| b309f91095 | |||
| c50a749293 | |||
| cd9e7c8a1f | |||
| 9d0767ccfe | |||
| 021e52d9eb | |||
| 607a5fc99d | |||
| 756be17cc7 | |||
| 00fa7beebd | |||
| b9d13de591 | |||
| 7896890ffc | |||
| a2043abb51 | |||
| 64bb1b3bb5 | |||
| a1a56402d1 | |||
| 582d3774a4 | |||
| fab08cfaf8 | |||
| 550c48f29e | |||
| 0c64580abf | |||
| 0ad9b7e233 | |||
| b9bcc45cce | |||
| bcd285e368 | |||
| c0ad98eb34 | |||
| 178278235e | |||
| 11664956c7 | |||
| f020979188 | |||
| da5c8d6773 | |||
| 31051cd6c4 | |||
| c51058e6ac | |||
| 3ba913bb21 | |||
| 95116f70e8 | |||
| 5b282b62e7 | |||
| 5e74fce6e2 | |||
| 96b279b350 | |||
| fc4ad562ae | |||
| 480a8df31a | |||
| f76e7f0e51 | |||
| b9ffbd179c | |||
| a3ef1d4a26 | |||
| d11bcc5de9 | |||
| 412914cf01 | |||
| d511aedd39 | |||
| 7edf7d194b | |||
| 6324608a4a | |||
| a402c2f46f | |||
| e21858febe | |||
| 3d615fa582 | |||
| 4293444d3e | |||
| a55a4d5b5d | |||
| dd9a87a33b | |||
| 24242ef01a | |||
| b537cdfd93 | |||
| 80cc7c723f | |||
| e9210213b1 | |||
| 634f1924be | |||
| 4e175a8361 | |||
| c5ce32ef79 | |||
| aaf6b03a25 | |||
| 66bc739411 | |||
| 1b48fa256e | |||
| 053c4a40e9 | |||
| 53d732d802 | |||
| 26ca81db40 | |||
| e6e6a3bde7 | |||
| 26fdaac589 | |||
| 1fd1cb71ba | |||
| c8023573c6 | |||
| 584be7e65b | |||
| 59ec88e84a | |||
| 1b2e0ad431 | |||
| 20464a2845 | |||
| ece6284de2 | |||
| 82e994e5b1 | |||
| 85aa7a7251 | |||
| c4ececd03a | |||
| 57bba04cf3 | |||
| 11e162dbd4 | |||
| e06e9d1496 | |||
| c8cc9909ba | |||
| c218a1f7a2 | |||
| 490aaba30a | |||
| 790d37569c | |||
| 1e140a01b5 | |||
| 4ecb7202ec | |||
| 4b7419fe4a | |||
| 44d3c738d7 | |||
| 888c66a218 | |||
| 86641bd160 | |||
| 3d91af23cc | |||
| 2d0d30ce01 | |||
| 98ae9af84a | |||
| 3dda12ada6 | |||
| 8b2750c413 | |||
| c16c27fa50 | |||
| b3ef57e1b6 | |||
| b554a6a542 | |||
| 574fd6dc89 | |||
| 8d77332a0a | |||
| 0e2c805b41 | |||
| 9782eb8c7c | |||
| f6bc57ba91 | |||
| 4d5aec9a49 | |||
| 74b296aa37 | |||
| 88fadd1d61 | |||
| cac58f49d3 | |||
| 12c27eedac | |||
| 6a7ec95493 | |||
| e41656a760 | |||
| eb90c0f28c | |||
| f3c4b0ac40 | |||
| 038e6368e4 | |||
| 028f20375f | |||
| ccc23ff46a | |||
| e3b7a1f77b | |||
| 69a8eea1ce | |||
| 20493edd07 | |||
| 79f7fcab6e | |||
| ce3a6dab12 | |||
| 1b57dd0ecf | |||
| c6318910c2 | |||
| 03b3630611 | |||
| 5b8c8d493a | |||
| b12fb6bf48 | |||
| a8f64f5a36 | |||
| 6ab5b3300a | |||
| 6189e6454d | |||
| a8b2c56f4b | |||
| b472220151 | |||
| 223cee3828 | |||
| 101be503bf | |||
| 47e2d85471 | |||
| 16321b3cb0 | |||
| 144cb56cda | |||
| e6a15e36b4 | |||
| 3541af5992 | |||
| 68f0ea3639 | |||
| 925eb92c60 | |||
| d965ee7060 | |||
| aef85bb1ab | |||
| 8f59b08088 | |||
| 366998b3d6 | |||
| c8ad0ab24f | |||
| d2bbcb0f51 | |||
| 40457bd68a | |||
| c21c5f604b | |||
| 66350b8fb5 | |||
| 378cc3a651 | |||
| 28f43fb2c0 | |||
| 37eea16a50 | |||
| fe8ed43aae | |||
| 671aa68804 | |||
| 6449aaf7ea | |||
| f8fff3dbdf | |||
| 1715ec10dd | |||
| 8c7aed4b00 | |||
| 217b19e42a | |||
| bd26a0abc1 | |||
| abb708aa49 | |||
| 5d35161ae3 | |||
| 0b6f58bf9c | |||
| 79519fef26 | |||
| d7b1fbaf28 | |||
| 26bc67d1b4 | |||
| b783a8bac6 | |||
| e7dc54d6c5 | |||
| 9a07fba991 | |||
| ca3b1f0297 | |||
| 01390b12fb | |||
| df1eca7c34 | |||
| b529e9c848 | |||
| 23a2d3bcac | |||
| 55749a9565 | |||
| 8115a39538 | |||
| 2416ca1127 | |||
| 1971f594cf | |||
| fb0ee9e8f6 | |||
| b261f2ee5b | |||
| aa54075e6b | |||
| 271b598cee | |||
| fc232759fb | |||
| a992aa8898 | |||
| 1b8b91a44d | |||
| 63c6f5a8a1 | |||
| 1970a4d6b1 | |||
| d406f31bd8 | |||
| 4a011024dd | |||
| 59b23ad1b9 | |||
| 93be2881d2 | |||
| daa125edf3 | |||
| c93e770704 | |||
| 1f57adc4da | |||
| 952e6970f0 | |||
| af0746eac1 |
@@ -1,4 +1,5 @@
|
|||||||
name: Docker Hub Workflow
|
name: Docker Hub Workflow
|
||||||
|
run-name: Docker Hub Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -48,9 +49,15 @@ jobs:
|
|||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
|
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
@@ -92,9 +99,15 @@ jobs:
|
|||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Helmfile lint
|
||||||
|
run-name: Helmfile lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
helmfile-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/helmfile/helmfile:latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
uses: numerique-gouv/action-helmfile-lint@main
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||||
|
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||||
|
helmfile-src: "src/helm"
|
||||||
|
repositories: "meet,secrets"
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: ~/.local/bin/ruff check .
|
run: ~/.local/bin/ruff check .
|
||||||
- name: Lint code with pylint
|
- name: Lint code with pylint
|
||||||
run: ~/.local/bin/pylint .
|
run: ~/.local/bin/pylint meet demo core
|
||||||
|
|
||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -160,6 +160,21 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: ~/.local/bin/pytest -n 2
|
run: ~/.local/bin/pytest -n 2
|
||||||
|
|
||||||
|
lint-front:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd src/frontend/ && npm ci
|
||||||
|
|
||||||
|
- name: Check linting
|
||||||
|
run: cd src/frontend/ && npm run lint
|
||||||
|
|
||||||
|
- name: Check format
|
||||||
|
run: cd src/frontend/ && npm run check
|
||||||
|
|
||||||
i18n-crowdin:
|
i18n-crowdin:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ creation_rules:
|
|||||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
||||||
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
||||||
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
||||||
|
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
|
||||||
|
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||||
|
|||||||
+16
-25
@@ -1,15 +1,14 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.10-slim-bullseye as base
|
FROM python:3.12.6-alpine3.20 as base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apt-get update && \
|
RUN apk update && \
|
||||||
apt-get -y upgrade && \
|
apk upgrade
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base as back-builder
|
FROM base as back-builder
|
||||||
@@ -38,12 +37,9 @@ RUN yarn install --frozen-lockfile && \
|
|||||||
FROM base as link-collector
|
FROM base as link-collector
|
||||||
ARG MEET_STATIC_ROOT=/data/static
|
ARG MEET_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Install libpangocairo & rdfind
|
RUN apk add \
|
||||||
RUN apt-get update && \
|
pango \
|
||||||
apt-get install -y \
|
rdfind
|
||||||
libpangocairo-1.0-0 \
|
|
||||||
rdfind && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy installed python dependencies
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /install /usr/local
|
COPY --from=back-builder /install /usr/local
|
||||||
@@ -66,17 +62,14 @@ FROM base as core
|
|||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install required system libs
|
RUN apk add \
|
||||||
RUN apt-get update && \
|
gettext \
|
||||||
apt-get install -y \
|
cairo \
|
||||||
gettext \
|
libffi-dev \
|
||||||
libcairo2 \
|
gdk-pixbuf \
|
||||||
libffi-dev \
|
pango \
|
||||||
libgdk-pixbuf2.0-0 \
|
shared-mime-info
|
||||||
libpango-1.0-0 \
|
|
||||||
libpangocairo-1.0-0 \
|
|
||||||
shared-mime-info && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy entrypoint
|
# Copy entrypoint
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
@@ -106,9 +99,7 @@ FROM core as backend-development
|
|||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apt-get update && \
|
RUN apk add postgresql-client
|
||||||
apt-get install -y postgresql-client && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Uninstall Meet and re-install it in editable mode along with development
|
# Uninstall Meet and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
|||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
|
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -141,7 +141,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
|
|||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
bin/pylint --diff-only=origin/main
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests
|
||||||
@@ -259,19 +259,19 @@ i18n-generate-and-upload: \
|
|||||||
# -- Mail generator
|
# -- Mail generator
|
||||||
|
|
||||||
mails-build: ## Convert mjml files to html and text
|
mails-build: ## Convert mjml files to html and text
|
||||||
@$(MAIL_YARN) build
|
@$(MAIL_NPM) run build
|
||||||
.PHONY: mails-build
|
.PHONY: mails-build
|
||||||
|
|
||||||
mails-build-html-to-plain-text: ## Convert html files to text
|
mails-build-html-to-plain-text: ## Convert html files to text
|
||||||
@$(MAIL_YARN) build-html-to-plain-text
|
@$(MAIL_NPM) run build-html-to-plain-text
|
||||||
.PHONY: mails-build-html-to-plain-text
|
.PHONY: mails-build-html-to-plain-text
|
||||||
|
|
||||||
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
||||||
@$(MAIL_YARN) build-mjml-to-html
|
@$(MAIL_NPM) run build-mjml-to-html
|
||||||
.PHONY: mails-build-mjml-to-html
|
.PHONY: mails-build-mjml-to-html
|
||||||
|
|
||||||
mails-install: ## install the mail generator
|
mails-install: ## install the mail generator
|
||||||
@$(MAIL_YARN) install
|
@$(MAIL_NPM) install
|
||||||
.PHONY: mails-install
|
.PHONY: mails-install
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
@@ -92,66 +91,3 @@ function _dc_exec() {
|
|||||||
function _django_manage() {
|
function _django_manage() {
|
||||||
_dc_run "app-dev" python manage.py "$@"
|
_dc_run "app-dev" python manage.py "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
|
||||||
# terraform directory.
|
|
||||||
#
|
|
||||||
# usage: _set_openstack_project
|
|
||||||
#
|
|
||||||
# If necessary the script will prompt the user to choose a project from those available
|
|
||||||
function _set_openstack_project() {
|
|
||||||
|
|
||||||
declare prompt
|
|
||||||
declare -a projects
|
|
||||||
declare -i default=1
|
|
||||||
declare -i choice=0
|
|
||||||
declare -i n_projects
|
|
||||||
|
|
||||||
# List projects by looking in the "./env.d/terraform" directory
|
|
||||||
# and store them in an array
|
|
||||||
read -r -a projects <<< "$(
|
|
||||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
|
||||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
|
||||||
xargs
|
|
||||||
)"
|
|
||||||
nb_projects=${#projects[@]}
|
|
||||||
|
|
||||||
if [[ ${nb_projects} -le 0 ]]; then
|
|
||||||
echo "There are no OpenStack projects defined..." >&2
|
|
||||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
|
||||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
|
||||||
exit 10
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${nb_projects} -gt 1 ]]; then
|
|
||||||
prompt="Select an OpenStack project to target:\\n"
|
|
||||||
for (( i=0; i<nb_projects; i++ )); do
|
|
||||||
prompt+="[$((i+1))] ${projects[$i]}"
|
|
||||||
if [[ $((i+1)) -eq ${default} ]]; then
|
|
||||||
prompt+=" (default)"
|
|
||||||
fi
|
|
||||||
prompt+="\\n"
|
|
||||||
done
|
|
||||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
|
||||||
prompt+="Your choice: "
|
|
||||||
read -r -p "$(echo -e "${prompt}")" choice
|
|
||||||
|
|
||||||
if [[ ${choice} -gt nb_projects ]]; then
|
|
||||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
|
||||||
exit 11
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${choice} -le 0 ]]; then
|
|
||||||
choice=${default}
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
project=${projects[$((choice-1))]}
|
|
||||||
# Check that the openrc.sh file exists for this project
|
|
||||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
|
||||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
|
||||||
exit 12
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${project}"
|
|
||||||
}
|
|
||||||
|
|||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# shellcheck source=bin/_config.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|
||||||
|
|
||||||
declare diff_from
|
|
||||||
declare -a paths
|
|
||||||
declare -a args
|
|
||||||
|
|
||||||
# Parse options
|
|
||||||
for arg in "$@"
|
|
||||||
do
|
|
||||||
case $arg in
|
|
||||||
--diff-only=*)
|
|
||||||
diff_from="${arg#*=}"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-*)
|
|
||||||
args+=("$arg")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
paths+=("$arg")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -n "${diff_from}" ]]; then
|
|
||||||
# Run pylint only on modified files located in src/backend
|
|
||||||
# (excluding deleted files and migration files)
|
|
||||||
# shellcheck disable=SC2207
|
|
||||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fix docker vs local path when project sources are mounted as a volume
|
|
||||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
|
||||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -eo pipefail
|
|
||||||
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|
||||||
|
|
||||||
project=$(_set_openstack_project)
|
|
||||||
echo "Using \"${project}\" project..."
|
|
||||||
|
|
||||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
|
||||||
|
|
||||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
|
||||||
# shellcheck disable=SC2068
|
|
||||||
DOCKER_USER="$(id -u):$(id -g)" \
|
|
||||||
PROJECT="${project}" \
|
|
||||||
docker compose run --rm \
|
|
||||||
-e OS_AUTH_URL \
|
|
||||||
-e OS_IDENTITY_API_VERSION \
|
|
||||||
-e OS_USER_DOMAIN_NAME \
|
|
||||||
-e OS_PROJECT_DOMAIN_NAME \
|
|
||||||
-e OS_TENANT_ID \
|
|
||||||
-e OS_TENANT_NAME \
|
|
||||||
-e OS_USERNAME \
|
|
||||||
-e OS_PASSWORD \
|
|
||||||
-e OS_REGION_NAME \
|
|
||||||
terraform-state "$@"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -eo pipefail
|
|
||||||
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|
||||||
|
|
||||||
project=$(_set_openstack_project)
|
|
||||||
echo "Using \"${project}\" project..."
|
|
||||||
|
|
||||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
|
||||||
|
|
||||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
|
||||||
# shellcheck disable=SC2068
|
|
||||||
DOCKER_USER="$(id -u):$(id -g)" \
|
|
||||||
PROJECT="${project}" \
|
|
||||||
docker compose run --rm \
|
|
||||||
-e OS_AUTH_URL \
|
|
||||||
-e OS_IDENTITY_API_VERSION \
|
|
||||||
-e OS_USER_DOMAIN_NAME \
|
|
||||||
-e OS_PROJECT_DOMAIN_NAME \
|
|
||||||
-e OS_TENANT_ID \
|
|
||||||
-e OS_TENANT_NAME \
|
|
||||||
-e OS_USERNAME \
|
|
||||||
-e OS_PASSWORD \
|
|
||||||
-e OS_REGION_NAME \
|
|
||||||
-e TF_VAR_user_name \
|
|
||||||
terraform "$@"
|
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HELMFILE=src/helm/helmfile.yaml
|
||||||
|
|
||||||
|
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
|
||||||
|
|
||||||
|
for env in $environments; do
|
||||||
|
echo "################### $env lint ###################"
|
||||||
|
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
||||||
|
echo -e "\n"
|
||||||
|
done
|
||||||
+19
-11
@@ -3,20 +3,28 @@
|
|||||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||||
|
|
||||||
1. Create a new branch named: `release/4.18.1`.
|
1. Create a new branch named: `release/4.18.1`.
|
||||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||||
|
|
||||||
- for backend, update the version number by hand in `pyproject.toml`,
|
- for backend, update the version number by hand in `pyproject.toml`,
|
||||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||||
```
|
|
||||||
|
...
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image:
|
||||||
|
repository: lasuite/meet-frontend
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||||
|
```
|
||||||
|
|
||||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||||
|
|
||||||
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
||||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||||
|
|||||||
@@ -41,3 +41,4 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
|||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_URL=http://localhost:7880
|
LIVEKIT_API_URL=http://localhost:7880
|
||||||
|
ALLOW_UNREGISTERED_ROOMS=False
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,9 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": []
|
"matchPackageNames": [
|
||||||
|
"eslint"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
|
||||||
+1
-1
Submodule secrets updated: 9da011f5c2...2ef2610071
@@ -1,4 +1,5 @@
|
|||||||
"""Admin classes and registrations for core app."""
|
"""Admin classes and registrations for core app."""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import admin as auth_admin
|
from django.contrib.auth import admin as auth_admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Meet analytics class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from june import analytics as jAnalytics
|
||||||
|
|
||||||
|
|
||||||
|
class Analytics:
|
||||||
|
"""Analytics integration
|
||||||
|
|
||||||
|
This class wraps the June analytics code to avoid coupling our code directly
|
||||||
|
with this third-party library. By doing so, we create a generic interface
|
||||||
|
for analytics that can be easily modified or replaced in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
key = getattr(settings, "ANALYTICS_KEY", None)
|
||||||
|
|
||||||
|
if key is not None:
|
||||||
|
jAnalytics.write_key = key
|
||||||
|
|
||||||
|
self._enabled = key is not None
|
||||||
|
|
||||||
|
def _is_anonymous_user(self, user):
|
||||||
|
"""Check if the user is anonymous."""
|
||||||
|
return user is None or user.is_anonymous
|
||||||
|
|
||||||
|
def identify(self, user, **kwargs):
|
||||||
|
"""Identify a user"""
|
||||||
|
|
||||||
|
if self._is_anonymous_user(user) or not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
traits = kwargs.pop("traits", {})
|
||||||
|
traits.update({"email": user.email_anonymized})
|
||||||
|
|
||||||
|
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
|
||||||
|
|
||||||
|
def track(self, user, **kwargs):
|
||||||
|
"""Track an event"""
|
||||||
|
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = {}
|
||||||
|
if self._is_anonymous_user(user):
|
||||||
|
event_data["anonymous_id"] = str(uuid.uuid4())
|
||||||
|
else:
|
||||||
|
event_data["user_id"] = user.sub
|
||||||
|
|
||||||
|
jAnalytics.track(**event_data, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
analytics = Analytics()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Meet core API endpoints"""
|
"""Meet core API endpoints"""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
|
|||||||
detail = exc.message
|
detail = exc.message
|
||||||
elif hasattr(exc, "messages"):
|
elif hasattr(exc, "messages"):
|
||||||
detail = exc.messages
|
detail = exc.messages
|
||||||
|
else:
|
||||||
|
detail = ""
|
||||||
|
|
||||||
exc = drf_exceptions.ValidationError(detail=detail)
|
exc = drf_exceptions.ValidationError(detail=detail)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
|
||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from minio import Minio
|
||||||
|
from django.conf import settings
|
||||||
|
import openai
|
||||||
|
import logging
|
||||||
|
from ..models import Room, RoleChoices
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(transcript, date):
|
||||||
|
return f"""
|
||||||
|
|
||||||
|
Audience: Coworkers.
|
||||||
|
|
||||||
|
**Do:**
|
||||||
|
- Detect the language of the transcript and provide your entire response in the same language.
|
||||||
|
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
|
||||||
|
- Ensure the accuracy of all information and refrain from adding unverified details.
|
||||||
|
- Format the response using proper markdown and structured sections.
|
||||||
|
- Be concise and avoid repeating yourself between the sections.
|
||||||
|
- Be super precise on nickname
|
||||||
|
- Be a nit-picker
|
||||||
|
- Auto-evaluate your response
|
||||||
|
|
||||||
|
**Don't:**
|
||||||
|
- Write something your are not sure.
|
||||||
|
- Write something that is not mention in the transcript.
|
||||||
|
- Don't make mistake while mentioning someone
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
|
||||||
|
|
||||||
|
1. **Summary**: Write a TL;DR of the meeting.
|
||||||
|
2. **Subjects Discussed**: List the key points or issues in bullet points.
|
||||||
|
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
|
||||||
|
|
||||||
|
**Transcript**:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
### Summary [Translate this title based on the transcript’s language]
|
||||||
|
[Provide a brief overview of the key points discussed]
|
||||||
|
|
||||||
|
### Subjects Discussed [Translate this title based on the transcript’s language]
|
||||||
|
- [Summarize each topic concisely]
|
||||||
|
|
||||||
|
### Next Steps [Translate this title based on the transcript’s language]
|
||||||
|
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_room_and_owners(slug):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(slug=slug)
|
||||||
|
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
|
||||||
|
owners = [access.user for access in owner_accesses]
|
||||||
|
|
||||||
|
logger.info("Room %s has owners: %s", slug, owners)
|
||||||
|
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
logger.error("Room with slug %s does not exist", slug)
|
||||||
|
|
||||||
|
owners = None
|
||||||
|
room = None
|
||||||
|
|
||||||
|
return room, owners
|
||||||
|
|
||||||
|
|
||||||
|
def remove_temporary_file(path):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
os.remove(path)
|
||||||
|
logger.info("Temporary file %s has been deleted.", path)
|
||||||
|
|
||||||
|
def get_blocknote_content(summary):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not settings.BLOCKNOTE_CONVERTER_URL:
|
||||||
|
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"markdown": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Converting summary in BlockNote.js…")
|
||||||
|
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'content' in response_data:
|
||||||
|
logger.error(f"Content is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = response_data['content']
|
||||||
|
logger.info("Base64 content: %s", content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_link(content, email):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Create a document for %s", email)
|
||||||
|
|
||||||
|
if not settings.DOCS_BASE_URL:
|
||||||
|
logger.error("DOCS_BASE_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"content": content,
|
||||||
|
"owner": email
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Querying docs…")
|
||||||
|
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'id' in response_data:
|
||||||
|
logger.error(f"ID is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
id = response_data['id']
|
||||||
|
logger.info("Document's id: %s", id)
|
||||||
|
|
||||||
|
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def email_owner_with_summary(room, link, owner):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Emailing owner: %s", owner)
|
||||||
|
|
||||||
|
try:
|
||||||
|
room.email_summary(owners=[owner], link=link)
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
logger.error("Error while emailing owner")
|
||||||
|
|
||||||
|
def strip_room_slug(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[2].split(".")[0]
|
||||||
|
|
||||||
|
def strip_room_date(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[1].split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Minio(
|
||||||
|
settings.MINIO_URL,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def download_temporary_file(minio_client, filename):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
|
||||||
|
logger.info('downloading file %s', filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
|
||||||
|
|
||||||
|
for data in audio_file_stream.stream(32 * 1024):
|
||||||
|
temp_audio_file.write(data)
|
||||||
|
|
||||||
|
temp_file_path = temp_audio_file.name
|
||||||
|
logger.info('Temporary file created at %s', temp_file_path)
|
||||||
|
|
||||||
|
audio_file_stream.close()
|
||||||
|
audio_file_stream.release_conn()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while accessing the object: %s", str(e))
|
||||||
|
|
||||||
|
return temp_file_path
|
||||||
|
|
||||||
|
|
||||||
|
# todo - discuss retry policy if the webhook fail
|
||||||
|
@api_view(["POST"])
|
||||||
|
def minio_webhook(request):
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
|
||||||
|
logger.info('Minio webhook sent %s', data)
|
||||||
|
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record['s3']
|
||||||
|
bucket = s3['bucket']
|
||||||
|
bucket_name = bucket['name']
|
||||||
|
object = s3['object']
|
||||||
|
filename = object['key']
|
||||||
|
|
||||||
|
if bucket_name != settings.MINIO_BUCKET:
|
||||||
|
logger.info('Not interested in this bucket: %s', bucket_name)
|
||||||
|
return Response("Not interested in this bucket")
|
||||||
|
|
||||||
|
if object['contentType'] != 'audio/ogg':
|
||||||
|
logger.info('Not interested in this file type: %s', object['contentType'])
|
||||||
|
return Response("Not interested in this file type")
|
||||||
|
|
||||||
|
room_slug = strip_room_slug(filename)
|
||||||
|
room_date = strip_room_date(filename)
|
||||||
|
logger.info('file received %s for room %s', filename, room_slug)
|
||||||
|
|
||||||
|
minio_client = get_minio_client()
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
summary = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_file_path = download_temporary_file(minio_client, filename)
|
||||||
|
|
||||||
|
if settings.OPENAI_ENABLE and temp_file_path:
|
||||||
|
logger.info('Initiating OpenAI client …')
|
||||||
|
openai_client = openai.OpenAI(
|
||||||
|
api_key=settings.OPENAI_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(temp_file_path, "rb") as audio_file:
|
||||||
|
logger.info('Querying transcription …')
|
||||||
|
transcript = openai_client.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=audio_file
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Transcript: \n %s', transcript)
|
||||||
|
prompt = get_prompt(transcript.text, room_date)
|
||||||
|
|
||||||
|
logger.info('Prompt: \n %s', prompt)
|
||||||
|
|
||||||
|
summary_response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = summary_response.choices[0].message.content
|
||||||
|
logger.info('Summary: \n %s', summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred: %s", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
remove_temporary_file(temp_file_path)
|
||||||
|
|
||||||
|
if not summary:
|
||||||
|
logger.error("Empty summary.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
room, owners = get_room_and_owners(room_slug)
|
||||||
|
|
||||||
|
if not owners or not room:
|
||||||
|
logger.error("No owners in room %s", room_slug)
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
content = get_blocknote_content(summary)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
logger.error("Empty content.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
owner = owners[0]
|
||||||
|
|
||||||
|
link = get_document_link(content, owner.email)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
logger.error("Empty link.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
email_owner_with_summary(room, link, owner)
|
||||||
|
|
||||||
|
return Response("")
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Permission handlers for the Meet core app."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Client serializers for the Meet core app."""
|
"""Client serializers for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
if role is not None or instance.is_public:
|
if role is not None or instance.is_public:
|
||||||
slug = f"{instance.id!s}".replace("-", "")
|
slug = f"{instance.id!s}".replace("-", "")
|
||||||
|
|
||||||
username = request.GET.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -19,6 +20,7 @@ from rest_framework import (
|
|||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
@@ -184,6 +186,13 @@ class RoomViewSet(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
|
||||||
|
analytics.track(
|
||||||
|
user=self.request.user,
|
||||||
|
event="Get Room",
|
||||||
|
properties={"slug": instance.slug},
|
||||||
|
)
|
||||||
|
|
||||||
except Http404:
|
except Http404:
|
||||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||||
raise
|
raise
|
||||||
@@ -232,6 +241,14 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
analytics.track(
|
||||||
|
user=self.request.user,
|
||||||
|
event="Create Room",
|
||||||
|
properties={
|
||||||
|
"slug": room.slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessListModelMixin:
|
class ResourceAccessListModelMixin:
|
||||||
"""List mixin for resource access API."""
|
"""List mixin for resource access API."""
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from mozilla_django_oidc.auth import (
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
@@ -79,6 +81,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
else:
|
else:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
|
analytics.identify(user=user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_user(self, claims):
|
def create_user(self, claims):
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from mozilla_django_oidc.views import (
|
|||||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..analytics import analytics
|
||||||
|
|
||||||
|
|
||||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||||
@@ -98,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
|||||||
|
|
||||||
logout_url = self.redirect_url
|
logout_url = self.redirect_url
|
||||||
|
|
||||||
|
analytics.track(
|
||||||
|
user=request.user,
|
||||||
|
event="Signed Out",
|
||||||
|
)
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
logout_url = self.construct_oidc_logout_url(request)
|
logout_url = self.construct_oidc_logout_url(request)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Core application enums declaration
|
Core application enums declaration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import global_settings, settings
|
from django.conf import global_settings, settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.7 on 2024-08-07 14:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_create_pg_trgm_extension'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='configuration',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0003_alter_room_configuration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='language',
|
||||||
|
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Declare and configure the models for the Meet core application
|
Declare and configure the models for the Meet core application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ from django.utils.functional import lazy
|
|||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -162,6 +165,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_anonymized(self):
|
||||||
|
"""Anonymize the email address by replacing the local part with asterisks."""
|
||||||
|
if not self.email:
|
||||||
|
return ""
|
||||||
|
return f"***@{self.email.split('@')[1]}"
|
||||||
|
|
||||||
|
|
||||||
class Resource(BaseModel):
|
class Resource(BaseModel):
|
||||||
"""Model to define access control"""
|
"""Model to define access control"""
|
||||||
@@ -288,7 +298,7 @@ class Room(Resource):
|
|||||||
|
|
||||||
configuration = models.JSONField(
|
configuration = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default={},
|
default=dict,
|
||||||
verbose_name=_("Visio room configuration"),
|
verbose_name=_("Visio room configuration"),
|
||||||
help_text=_("Values for Visio parameters to configure the room."),
|
help_text=_("Values for Visio parameters to configure the room."),
|
||||||
)
|
)
|
||||||
@@ -317,3 +327,24 @@ class Room(Resource):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
|
||||||
|
def email_summary(self, owners, link):
|
||||||
|
"""Wip"""
|
||||||
|
|
||||||
|
template_vars = {
|
||||||
|
"title": "Votre résumé est prêt",
|
||||||
|
"link": link,
|
||||||
|
"room": self.slug,
|
||||||
|
}
|
||||||
|
msg_html = render_to_string("mail/html/summary.html", template_vars)
|
||||||
|
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||||
|
|
||||||
|
for owner in owners:
|
||||||
|
owner.email_user(
|
||||||
|
subject="Votre résumé est prêt",
|
||||||
|
from_email=settings.EMAIL_FROM,
|
||||||
|
message=msg_plain,
|
||||||
|
html_message=msg_html,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
with django_assert_num_queries(0), pytest.raises(
|
with (
|
||||||
SuspiciousOperation,
|
django_assert_num_queries(0),
|
||||||
match="User info contained no recognizable user identification",
|
pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match="User info contained no recognizable user identification",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Fixtures for tests in the Meet core application"""
|
"""Fixtures for tests in the Meet core application"""
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test rooms API endpoints in the Meet core app: create.
|
Test rooms API endpoints in the Meet core app: create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test rooms API endpoints in the Meet core app: delete.
|
Test rooms API endpoints in the Meet core app: delete.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test rooms API endpoints in the Meet core app: list.
|
Test rooms API endpoints in the Meet core app: list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test rooms API endpoints in the Meet core app: retrieve.
|
Test rooms API endpoints in the Meet core app: retrieve.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test rooms API endpoints in the Meet core app: update.
|
Test rooms API endpoints in the Meet core app: update.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test suite for generated openapi schema.
|
Test suite for generated openapi schema.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
Test for the Analytics class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.analytics import Analytics
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mock_june_analytics")
|
||||||
|
def _mock_june_analytics():
|
||||||
|
with patch("core.analytics.jAnalytics") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_init_enabled(mock_june_analytics):
|
||||||
|
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
|
||||||
|
analytics = Analytics()
|
||||||
|
assert analytics._enabled is True
|
||||||
|
assert mock_june_analytics.write_key == "test_key"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_init_disabled():
|
||||||
|
"""Should disable analytics when ANALYTICS_KEY is not set."""
|
||||||
|
analytics = Analytics()
|
||||||
|
assert analytics._enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_user(mock_june_analytics):
|
||||||
|
"""Should identify a user with the correct traits when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_called_once_with(
|
||||||
|
user_id="12345", traits={"email": "***@example.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_user_with_traits(mock_june_analytics):
|
||||||
|
"""Should identify a user with additional traits when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
|
||||||
|
mock_june_analytics.identify.assert_called_once_with(
|
||||||
|
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_identify_not_enabled(mock_june_analytics):
|
||||||
|
"""Should not call identify when analytics is not enabled."""
|
||||||
|
user = UserFactory(sub="12345", email="user@example.com")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_no_user(mock_june_analytics):
|
||||||
|
"""Should not call identify when the user is None."""
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(None)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_identify_anonymous_user(mock_june_analytics):
|
||||||
|
"""Should not call identify when the user is anonymous."""
|
||||||
|
user = AnonymousUser()
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.identify(user)
|
||||||
|
mock_june_analytics.identify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
def test_analytics_track_event(mock_june_analytics):
|
||||||
|
"""Should track an event with the correct user and event details when analytics is enabled."""
|
||||||
|
user = UserFactory(sub="12345")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
user_id="12345", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY=None)
|
||||||
|
def test_analytics_track_event_not_enabled(mock_june_analytics):
|
||||||
|
"""Should not call track when analytics is not enabled."""
|
||||||
|
user = UserFactory(sub="12345")
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
|
||||||
|
mock_june_analytics.track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||||
|
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
|
||||||
|
"""Should track an event with a random anonymous user ID when the user is None."""
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(None, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
mock_uuid4.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANALYTICS_KEY="test_key")
|
||||||
|
@patch("uuid.uuid4", return_value="test_uuid4")
|
||||||
|
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
|
||||||
|
"""Should track an event with a random anonymous user ID when the user is anonymous."""
|
||||||
|
user = AnonymousUser()
|
||||||
|
analytics = Analytics()
|
||||||
|
analytics.track(user, event="test_event", foo="foo")
|
||||||
|
mock_june_analytics.track.assert_called_once_with(
|
||||||
|
anonymous_id="test_uuid4", event="test_event", foo="foo"
|
||||||
|
)
|
||||||
|
mock_uuid4.assert_called_once()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test resource accesses API endpoints in the Meet core app.
|
Test resource accesses API endpoints in the Meet core app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test users API endpoints in the Meet core app.
|
Test users API endpoints in the Meet core app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for the ResourceAccess model with user
|
Unit tests for the ResourceAccess model with user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for the Room model
|
Unit tests for the Room model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for the User model
|
Unit tests for the User model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -43,3 +44,12 @@ def test_models_users_send_mail_main_missing():
|
|||||||
user.email_user("my subject", "my message")
|
user.email_user("my subject", "my message")
|
||||||
|
|
||||||
assert str(excinfo.value) == "User has no email address."
|
assert str(excinfo.value) == "User has no email address."
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_users_email_anonymized():
|
||||||
|
"""The user's email should be anonymized if it exists."""
|
||||||
|
user = factories.UserFactory(email="john.doe@world.com")
|
||||||
|
assert user.email_anonymized == "***@world.com"
|
||||||
|
|
||||||
|
user = factories.UserFactory(email=None)
|
||||||
|
assert user.email_anonymized == ""
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""URL configuration for the core app."""
|
"""URL configuration for the core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import viewsets
|
from core.api import get_frontend_configuration, viewsets, demo
|
||||||
from core.authentication.urls import urlpatterns as oidc_urls
|
from core.authentication.urls import urlpatterns as oidc_urls
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -22,6 +23,8 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path("minio-webhook/", demo.minio_webhook, name="demo"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+44
-10
@@ -1,6 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Utils functions used in the core app
|
Utils functions used in the core app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa:S311
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -9,8 +15,30 @@ from django.conf import settings
|
|||||||
from livekit.api import AccessToken, VideoGrants
|
from livekit.api import AccessToken, VideoGrants
|
||||||
|
|
||||||
|
|
||||||
|
def generate_color(identity: str) -> str:
|
||||||
|
"""Generates a consistent HSL color based on a given identity string.
|
||||||
|
|
||||||
|
The function seeds the random generator with the identity's hash,
|
||||||
|
ensuring consistent color output. The HSL format allows fine-tuned control
|
||||||
|
over saturation and lightness, empirically adjusted to produce visually
|
||||||
|
appealing and distinct colors. HSL is preferred over hex to constrain the color
|
||||||
|
range and ensure predictability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa:S324
|
||||||
|
identity_hash = hashlib.sha1(identity.encode("utf-8"))
|
||||||
|
# Keep only hash's last 16 bits, collisions are not a concern
|
||||||
|
seed = int(identity_hash.hexdigest(), 16) & 0xFFFF
|
||||||
|
random.seed(seed)
|
||||||
|
hue = random.randint(0, 360)
|
||||||
|
saturation = random.randint(50, 75)
|
||||||
|
lightness = random.randint(25, 60)
|
||||||
|
|
||||||
|
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||||
|
|
||||||
|
|
||||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||||
"""Generate a Livekit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
room (str): The name of the room.
|
room (str): The name of the room.
|
||||||
@@ -21,10 +49,12 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
video_grants = VideoGrants(
|
video_grants = VideoGrants(
|
||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
|
room_admin=True,
|
||||||
|
room_record=True,
|
||||||
|
can_update_own_metadata=True,
|
||||||
can_publish_sources=[
|
can_publish_sources=[
|
||||||
"camera",
|
"camera",
|
||||||
"microphone",
|
"microphone",
|
||||||
@@ -33,18 +63,22 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
token = AccessToken(
|
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
|
||||||
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
|
||||||
).with_grants(video_grants)
|
|
||||||
|
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
token.with_identity(str(uuid4()))
|
identity = str(uuid4())
|
||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
token.with_identity(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = str(user)
|
||||||
|
|
||||||
token.with_name(username or default_username)
|
token = (
|
||||||
|
AccessToken(
|
||||||
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
|
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
)
|
||||||
|
.with_grants(video_grants)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name(username or default_username)
|
||||||
|
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||||
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Management user to create a superuser."""
|
"""Management user to create a superuser."""
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
meet's sandbox management script.
|
meet's sandbox management script.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Meet celery configuration file."""
|
"""Meet celery configuration file."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
|
|||||||
For the full list of settings and their values, see
|
For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ class Base(Configuration):
|
|||||||
# Security
|
# Security
|
||||||
ALLOWED_HOSTS = values.ListValue([])
|
ALLOWED_HOSTS = values.ListValue([])
|
||||||
SECRET_KEY = values.Value(None)
|
SECRET_KEY = values.Value(None)
|
||||||
|
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
ROOT_URLCONF = "meet.urls"
|
ROOT_URLCONF = "meet.urls"
|
||||||
@@ -248,6 +251,19 @@ class Base(Configuration):
|
|||||||
"REDOC_DIST": "SIDECAR",
|
"REDOC_DIST": "SIDECAR",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_CONFIGURATION = {
|
||||||
|
"analytics": values.DictValue(
|
||||||
|
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
|
||||||
|
),
|
||||||
|
"support": values.DictValue(
|
||||||
|
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
|
||||||
|
),
|
||||||
|
"silence_livekit_debug_logs": values.BooleanValue(
|
||||||
|
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
||||||
EMAIL_HOST = values.Value(None)
|
EMAIL_HOST = values.Value(None)
|
||||||
@@ -255,6 +271,7 @@ class Base(Configuration):
|
|||||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||||
|
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||||
EMAIL_FROM = values.Value("from@example.com")
|
EMAIL_FROM = values.Value("from@example.com")
|
||||||
|
|
||||||
AUTH_USER_MODEL = "core.User"
|
AUTH_USER_MODEL = "core.User"
|
||||||
@@ -272,6 +289,7 @@ class Base(Configuration):
|
|||||||
# Easy thumbnails
|
# Easy thumbnails
|
||||||
THUMBNAIL_EXTENSION = "webp"
|
THUMBNAIL_EXTENSION = "webp"
|
||||||
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
||||||
|
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||||
THUMBNAIL_ALIASES = {}
|
THUMBNAIL_ALIASES = {}
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
@@ -364,6 +382,56 @@ class Base(Configuration):
|
|||||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
ANALYTICS_KEY = values.Value(
|
||||||
|
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo - totally wip
|
||||||
|
AWS_S3_ENDPOINT_URL = values.Value(
|
||||||
|
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_ACCESS_KEY_ID = values.Value(
|
||||||
|
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
||||||
|
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_REGION_NAME = values.Value(
|
||||||
|
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_STORAGE_BUCKET_NAME = values.Value(
|
||||||
|
"meet-media-storage",
|
||||||
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = values.Value(
|
||||||
|
None, environ_name="OPENAI_API_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
OPENAI_ENABLE = values.BooleanValue(
|
||||||
|
True, environ_name="OPENAI_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo - totally wip
|
||||||
|
MINIO_ACCESS_KEY = values.Value(
|
||||||
|
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_SECRET_KEY = values.Value(
|
||||||
|
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_URL = values.Value(
|
||||||
|
None, environ_name="MINIO_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_BUCKET = values.Value(
|
||||||
|
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
BLOCKNOTE_CONVERTER_URL = values.Value(
|
||||||
|
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
|
||||||
|
)
|
||||||
|
DOCS_BASE_URL = values.Value(
|
||||||
|
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
@@ -484,6 +552,8 @@ class Test(Base):
|
|||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||||
|
|
||||||
|
ANALYTICS_KEY = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
@@ -506,8 +576,26 @@ class Production(Base):
|
|||||||
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ALLOWED_HOSTS = values.ListValue(None)
|
ALLOWED_HOSTS = [
|
||||||
|
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||||
|
gethostbyname(gethostname()),
|
||||||
|
]
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
@@ -522,6 +610,14 @@ class Production(Base):
|
|||||||
#
|
#
|
||||||
# In other cases, you should comment the following line to avoid security issues.
|
# In other cases, you should comment the following line to avoid security issues.
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
SECURE_HSTS_SECONDS = 60
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SECURE_REDIRECT_EXEMPT = [
|
||||||
|
"^__lbheartbeat__",
|
||||||
|
"^__heartbeat__",
|
||||||
|
]
|
||||||
|
|
||||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|||||||
+42
-39
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.2"
|
version = "0.1.7"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -25,38 +25,41 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.33.6",
|
"boto3==1.35.19",
|
||||||
"Brotli==1.1.0",
|
"Brotli==1.1.0",
|
||||||
"celery[redis]==5.3.6",
|
"celery[redis]==5.4.0",
|
||||||
"django-configurations==2.5",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.3.1",
|
"django-cors-headers==4.4.0",
|
||||||
"django-countries==7.5.1",
|
"django-countries==7.6.1",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.0.3",
|
"redis==5.0.8",
|
||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
"django-storages[s3]==1.14.2",
|
"django-storages[s3]==1.14.4",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.0.7",
|
"django==5.1.1",
|
||||||
"djangorestframework==3.15.2",
|
"djangorestframework==3.15.2",
|
||||||
"drf_spectacular==0.26.5",
|
"drf_spectacular==0.27.2",
|
||||||
"dockerflow==2022.8.0",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.8.5",
|
"easy_thumbnails==2.10",
|
||||||
"factory_boy==3.3.0",
|
"factory_boy==3.3.1",
|
||||||
"freezegun==1.5.0",
|
"freezegun==1.5.1",
|
||||||
"gunicorn==22.0.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.20.0",
|
"jsonschema==4.23.0",
|
||||||
"markdown==3.5.1",
|
"june-analytics-python==2.3.0",
|
||||||
|
"markdown==3.7",
|
||||||
"nested-multipart-parser==1.5.0",
|
"nested-multipart-parser==1.5.0",
|
||||||
"psycopg[binary]==3.1.14",
|
"psycopg[binary]==3.2.2",
|
||||||
"PyJWT==2.8.0",
|
"PyJWT==2.9.0",
|
||||||
"python-frontmatter==1.0.1",
|
"python-frontmatter==1.1.0",
|
||||||
"requests==2.32.2",
|
"requests==2.32.3",
|
||||||
"sentry-sdk==2.8.0",
|
"sentry-sdk==2.14.0",
|
||||||
"url-normalize==1.4.3",
|
"url-normalize==1.4.3",
|
||||||
"WeasyPrint>=60.2",
|
"WeasyPrint>=60.2",
|
||||||
"whitenoise==6.6.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.0",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.5.1",
|
"livekit-api==0.7.0",
|
||||||
|
"minio==7.2.9",
|
||||||
|
"openai==1.51.2"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -68,20 +71,20 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2023.12.1",
|
"drf-spectacular-sidecar==2024.7.1",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.18.1",
|
"ipython==8.27.0",
|
||||||
"pyfakefs==5.3.2",
|
"pyfakefs==5.6.0",
|
||||||
"pylint-django==2.5.5",
|
"pylint-django==2.5.5",
|
||||||
"pylint==3.0.3",
|
"pylint==3.2.7",
|
||||||
"pytest-cov==4.1.0",
|
"pytest-cov==5.0.0",
|
||||||
"pytest-django==4.7.0",
|
"pytest-django==4.9.0",
|
||||||
"pytest==7.4.3",
|
"pytest==8.3.3",
|
||||||
"pytest-icdiff==0.8",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.5.0",
|
"pytest-xdist==3.6.1",
|
||||||
"responses==0.24.1",
|
"responses==0.25.3",
|
||||||
"ruff==0.1.6",
|
"ruff==0.6.5",
|
||||||
"types-requests==2.31.0.10",
|
"types-requests==2.32.0.20240914",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
@@ -100,7 +103,6 @@ exclude = [
|
|||||||
"__pycache__",
|
"__pycache__",
|
||||||
"*/migrations/*",
|
"*/migrations/*",
|
||||||
]
|
]
|
||||||
ignore= ["DJ001", "PLR2004"]
|
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
|
|
||||||
@@ -121,12 +123,13 @@ select = [
|
|||||||
"SLF", # flake8-self
|
"SLF", # flake8-self
|
||||||
"T20", # flake8-print
|
"T20", # flake8-print
|
||||||
]
|
]
|
||||||
|
ignore= ["DJ001", "PLR2004"]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
section-order = ["future","standard-library","django","third-party","meet","first-party","local-folder"]
|
section-order = ["future","standard-library","django","third-party","meet","first-party","local-folder"]
|
||||||
sections = { meet=["core"], django=["django"] }
|
sections = { meet=["core"], django=["django"] }
|
||||||
|
|
||||||
[tool.ruff.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"**/tests/*" = ["S", "SLF"]
|
"**/tests/*" = ["S", "SLF"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Setup file for the meet module. All configuration stands in the setup.cfg file."""
|
|
||||||
# coding: utf-8
|
|
||||||
|
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
setup()
|
|
||||||
@@ -7,6 +7,7 @@ module.exports = {
|
|||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
'plugin:@tanstack/eslint-plugin-query/recommended',
|
||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
|
'prettier'
|
||||||
],
|
],
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
|
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Meet</title>
|
<title>Visio</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2654
-2803
File diff suppressed because it is too large
Load Diff
+38
-28
@@ -1,50 +1,60 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
"build": "panda codegen && tsc -b && vite build",
|
"build": "panda codegen && tsc -b && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"i18n:extract": "npx i18next -c i18next-parser.config.json"
|
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||||
|
"format": "prettier --write ./src",
|
||||||
|
"check": "prettier --check ./src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.3.3",
|
"@livekit/components-react": "2.6.5",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.1.3",
|
||||||
"@pandacss/preset-panda": "0.41.0",
|
"@livekit/track-processors": "0.3.2",
|
||||||
|
"@pandacss/preset-panda": "0.46.1",
|
||||||
|
"@react-aria/toast": "3.0.0-beta.16",
|
||||||
"@remixicon/react": "4.2.0",
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.49.2",
|
"@tanstack/react-query": "5.59.4",
|
||||||
|
"crisp-sdk-web": "1.0.25",
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
"i18next": "23.12.1",
|
"i18next": "23.15.2",
|
||||||
"i18next-browser-languagedetector": "8.0.0",
|
"i18next-browser-languagedetector": "8.0.0",
|
||||||
"i18next-parser": "9.0.0",
|
"i18next-parser": "9.0.2",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.3.1",
|
"livekit-client": "2.5.7",
|
||||||
"react": "18.2.0",
|
"posthog-js": "1.167.0",
|
||||||
"react-aria-components": "1.2.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.2.0",
|
"react-aria-components": "1.4.0",
|
||||||
"react-i18next": "14.1.3",
|
"react-dom": "18.3.1",
|
||||||
"wouter": "3.3.0"
|
"react-i18next": "15.0.2",
|
||||||
|
"use-sound": "4.0.3",
|
||||||
|
"valtio": "2.0.0",
|
||||||
|
"wouter": "3.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "0.41.0",
|
"@pandacss/dev": "0.46.1",
|
||||||
"@tanstack/eslint-plugin-query": "5.49.1",
|
"@tanstack/eslint-plugin-query": "5.59.2",
|
||||||
"@tanstack/react-query-devtools": "5.49.2",
|
"@tanstack/react-query-devtools": "5.59.4",
|
||||||
"@types/node": "20.14.9",
|
"@types/node": "20.16.11",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.11",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "7.13.1",
|
"@typescript-eslint/eslint-plugin": "8.8.1",
|
||||||
"@typescript-eslint/parser": "7.13.1",
|
"@typescript-eslint/parser": "8.8.1",
|
||||||
"@vitejs/plugin-react": "4.3.1",
|
"@vitejs/plugin-react": "4.3.2",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||||
"eslint-plugin-react-hooks": "4.6.2",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-refresh": "0.4.7",
|
"eslint-plugin-react-refresh": "0.4.12",
|
||||||
"postcss": "8.4.39",
|
"postcss": "8.4.47",
|
||||||
"typescript": "5.5.2",
|
"prettier": "3.3.3",
|
||||||
"vite": "5.3.1",
|
"typescript": "5.6.3",
|
||||||
"vite-tsconfig-paths": "4.3.2"
|
"vite": "5.4.8",
|
||||||
|
"vite-tsconfig-paths": "5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||||
|
pulse: {
|
||||||
|
'0%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0.7)' },
|
||||||
|
'75%': { boxShadow: '0 0 0 30px rgba(255, 255, 255, 0)' },
|
||||||
|
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
||||||
|
},
|
||||||
|
active_speaker: {
|
||||||
|
'0%': { height: '25%' },
|
||||||
|
'25%': { height: '45%' },
|
||||||
|
'50%': { height: '20%' },
|
||||||
|
'100%': { height: '55%' },
|
||||||
|
},
|
||||||
|
active_speaker_small: {
|
||||||
|
'0%': { height: '20%' },
|
||||||
|
'25%': { height: '25%' },
|
||||||
|
'50%': { height: '18%' },
|
||||||
|
'100%': { height: '25%' },
|
||||||
|
},
|
||||||
|
wave_hand: {
|
||||||
|
'0%': { transform: 'rotate(0deg)' },
|
||||||
|
'20%': { transform: 'rotate(-20deg)' },
|
||||||
|
'80%': { transform: 'rotate(20deg)' },
|
||||||
|
'100%': { transform: 'rotate(0)' },
|
||||||
|
},
|
||||||
|
pulse_mic: {
|
||||||
|
'0%': { color: 'primary', opacity: '1' },
|
||||||
|
'50%': { color: 'primary', opacity: '0.8' },
|
||||||
|
'100%': { color: 'primary', opacity: '1' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
@@ -199,6 +227,7 @@ const config: Config = {
|
|||||||
text: { value: '{colors.white}' },
|
text: { value: '{colors.white}' },
|
||||||
subtle: { value: '{colors.red.100}' },
|
subtle: { value: '{colors.red.100}' },
|
||||||
'subtle-text': { value: '{colors.red.700}' },
|
'subtle-text': { value: '{colors.red.700}' },
|
||||||
|
...pandaPreset.theme.tokens.colors.red,
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
DEFAULT: { value: '{colors.green.700}' },
|
DEFAULT: { value: '{colors.green.700}' },
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+18
-14
@@ -2,32 +2,36 @@ import '@livekit/components-styles'
|
|||||||
import '@/styles/index.css'
|
import '@/styles/index.css'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useLang } from 'hoofd'
|
import { useLang } from 'hoofd'
|
||||||
import { Switch, Route } from 'wouter'
|
import { Switch, Route } from 'wouter'
|
||||||
import { NotFoundScreen } from './layout/NotFoundScreen'
|
import { I18nProvider } from 'react-aria-components'
|
||||||
import { RenderIfUserFetched } from './features/auth'
|
import { Layout } from './layout/Layout'
|
||||||
|
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
import './i18n/init'
|
import './i18n/init'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
const queryClient = new QueryClient()
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
useLang(i18n.language)
|
useLang(i18n.language)
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AppInitialization />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RenderIfUserFetched>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route key={i} path={route.path} component={route.Component} />
|
||||||
<Route component={NotFoundScreen} />
|
))}
|
||||||
</Switch>
|
<Route component={NotFoundScreen} />
|
||||||
</RenderIfUserFetched>
|
</Switch>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
</Layout>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</I18nProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const keys = {
|
export const keys = {
|
||||||
user: 'user',
|
user: 'user',
|
||||||
room: 'room',
|
room: 'room',
|
||||||
|
config: 'config',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { fetchApi } from './fetchApi'
|
||||||
|
import { keys } from './queryKeys'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export interface ApiConfig {
|
||||||
|
analytics?: {
|
||||||
|
id: string
|
||||||
|
host: string
|
||||||
|
}
|
||||||
|
support?: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
silence_livekit_debug_logs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
return fetchApi<ApiConfig>(`config/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfig = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [keys.config],
|
||||||
|
queryFn: fetchConfig,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||||
|
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||||
|
|
||||||
|
export const AppInitialization = () => {
|
||||||
|
const { data } = useConfig()
|
||||||
|
|
||||||
|
const {
|
||||||
|
analytics = {},
|
||||||
|
support = {},
|
||||||
|
silence_livekit_debug_logs = false,
|
||||||
|
} = data || {}
|
||||||
|
|
||||||
|
useAnalytics(analytics)
|
||||||
|
useSupport(support)
|
||||||
|
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const avatar = cva({
|
||||||
|
base: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: '50%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
context: {
|
||||||
|
list: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
context: 'list',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
name?: string
|
||||||
|
bgColor?: string
|
||||||
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
|
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
|
||||||
|
const initial = name?.trim()?.charAt(0) || ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
}}
|
||||||
|
className={avatar({ context })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Link } from '@/primitives'
|
||||||
|
import { AProps } from '@/primitives/A'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const BackToHome = ({ size }: { size?: AProps['size'] }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<Link to="/" size={size}>
|
||||||
|
{t('backToHome')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export const DelayedRender = ({
|
||||||
|
children,
|
||||||
|
delay = 500,
|
||||||
|
}: {
|
||||||
|
delay?: number
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (delay === 0) {
|
||||||
|
setShow(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => setShow(true), delay)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [delay])
|
||||||
|
if (delay !== 0 && !show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Center } from '@/styled-system/jsx'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
export const ErrorScreen = ({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<CenteredContent title={title || t('error.heading')} withBackButton>
|
||||||
|
{!!body && (
|
||||||
|
<Center>
|
||||||
|
<Text as="p" variant="h3" centered>
|
||||||
|
{body}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Button } from '@/primitives'
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiExternalLinkLine } from '@remixicon/react'
|
import { RiExternalLinkLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LinkButton } from '@/primitives'
|
||||||
|
|
||||||
export const Feedback = () => {
|
export const Feedback = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Button
|
<LinkButton
|
||||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||||
variant="success"
|
variant="success"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -20,6 +20,6 @@ export const Feedback = () => {
|
|||||||
className={css({ marginLeft: 0.5 })}
|
className={css({ marginLeft: 0.5 })}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</LinkButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Screen, type ScreenProps } from '@/layout/Screen'
|
||||||
|
import { DelayedRender } from './DelayedRender'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Center } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
export const LoadingScreen = ({
|
||||||
|
delay = 500,
|
||||||
|
header = undefined,
|
||||||
|
layout = 'centered',
|
||||||
|
}: {
|
||||||
|
delay?: number
|
||||||
|
} & Omit<ScreenProps, 'children'>) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DelayedRender delay={delay}>
|
||||||
|
<Screen layout={layout} header={header}>
|
||||||
|
<CenteredContent>
|
||||||
|
<Center>
|
||||||
|
<p>{t('loading')}</p>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
</DelayedRender>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const NotFoundScreen = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<CenteredContent title={t('notFound.heading')} withBackButton />
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
|||||||
|
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an error or loading Screen while a given `status` is not a success,
|
||||||
|
* otherwise directly render children.
|
||||||
|
*
|
||||||
|
* `status` matches react query statuses.
|
||||||
|
*
|
||||||
|
* Children usually contain a Screen at some point in the render tree.
|
||||||
|
*/
|
||||||
|
export const QueryAware = ({
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
status: 'error' | 'idle' | 'pending' | 'success'
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => {
|
||||||
|
if (status === 'error') {
|
||||||
|
return <ErrorScreen />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <LoadingScreen header={undefined} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export const SoundTester = () => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
|
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateActiveId = async (deviceId: string) => {
|
||||||
|
try {
|
||||||
|
await audioRef?.current?.setSinkId(deviceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting sinkId: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateActiveId(activeDeviceId)
|
||||||
|
}, [activeDeviceId])
|
||||||
|
|
||||||
|
// prevent pausing the sound
|
||||||
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
invisible
|
||||||
|
onPress={() => {
|
||||||
|
audioRef?.current?.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
isDisabled={isPlaying}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
color: isPlaying ? 'var(--colors-primary)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? t('audio.speakers.ongoingTest') : t('audio.speakers.test')}
|
||||||
|
</Button>
|
||||||
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src="sounds/uprise.mp3"
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'wouter'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
|
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
|
|
||||||
|
export const startAnalyticsSession = (data: ApiUser) => {
|
||||||
|
if (posthog._isIdentified()) return
|
||||||
|
const { id, email } = data
|
||||||
|
posthog.identify(id, { email })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminateAnalyticsSession = () => {
|
||||||
|
if (!posthog._isIdentified()) return
|
||||||
|
posthog.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type useAnalyticsProps = {
|
||||||
|
id?: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
||||||
|
const [location] = useLocation()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !host) return
|
||||||
|
if (posthog.__loaded) return
|
||||||
|
posthog.init(id, {
|
||||||
|
api_host: host,
|
||||||
|
person_profiles: 'always',
|
||||||
|
})
|
||||||
|
}, [id, host])
|
||||||
|
|
||||||
|
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||||
|
useEffect(() => {
|
||||||
|
posthog.capture('$pageview')
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
|
|||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// so that consumers of fetchUser don't think the work already ended
|
||||||
if (canAttemptSilentLogin()) {
|
if (canAttemptSilentLogin()) {
|
||||||
attemptSilentLogin(3600)
|
attemptSilentLogin(300)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { fetchUser } from './fetchUser'
|
import { fetchUser } from './fetchUser'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||||
|
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns info about currently logged in user
|
* returns info about currently logged-in user
|
||||||
*
|
*
|
||||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||||
*/
|
*/
|
||||||
@@ -12,8 +15,16 @@ export const useUser = () => {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [keys.user],
|
queryKey: [keys.user],
|
||||||
queryFn: fetchUser,
|
queryFn: fetchUser,
|
||||||
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query?.data) {
|
||||||
|
startAnalyticsSession(query.data)
|
||||||
|
initializeSupportSession(query.data)
|
||||||
|
}
|
||||||
|
}, [query.data])
|
||||||
|
|
||||||
const isLoggedIn =
|
const isLoggedIn =
|
||||||
query.status === 'success' ? query.data !== false : undefined
|
query.status === 'success' ? query.data !== false : undefined
|
||||||
const isLoggedOut = isLoggedIn === false
|
const isLoggedOut = isLoggedIn === false
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { type ReactNode } from 'react'
|
|
||||||
import { useUser } from '@/features/auth'
|
|
||||||
import { LoadingScreen } from '@/layout/LoadingScreen'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* wrapper that renders children only when user info has been actually fetched
|
|
||||||
*
|
|
||||||
* this is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
|
|
||||||
*/
|
|
||||||
export const RenderIfUserFetched = ({ children }: { children: ReactNode }) => {
|
|
||||||
const { isLoggedIn } = useUser()
|
|
||||||
return isLoggedIn !== undefined ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<LoadingScreen renderTimeout={1000} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a loading Screen while user info has not been fetched yet,
|
||||||
|
* otherwise directly render children.
|
||||||
|
*
|
||||||
|
* Children usually contain a Screen at some point in the render tree.
|
||||||
|
*
|
||||||
|
* This is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
|
||||||
|
*/
|
||||||
|
export const UserAware = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
|
|
||||||
|
return isLoggedIn !== undefined ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<LoadingScreen header={false} delay={1000} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { useUser } from './api/useUser'
|
export { useUser } from './api/useUser'
|
||||||
export { authUrl } from './utils/authUrl'
|
export { authUrl } from './utils/authUrl'
|
||||||
export { logoutUrl } from './utils/logoutUrl'
|
export { logoutUrl } from './utils/logoutUrl'
|
||||||
export { RenderIfUserFetched } from './components/RenderIfUserFetched'
|
export { UserAware } from './components/UserAware'
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ export const authUrl = ({
|
|||||||
silent = false,
|
silent = false,
|
||||||
returnTo = window.location.href,
|
returnTo = window.location.href,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
|
return apiUrl(
|
||||||
|
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { authUrl } from "@/features/auth";
|
import { authUrl } from '@/features/auth'
|
||||||
|
|
||||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
||||||
|
|
||||||
const isRetryAllowed = () => {
|
const isRetryAllowed = () => {
|
||||||
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
|
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY)
|
||||||
if (!lastRetryDate) {
|
if (!lastRetryDate) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date()
|
||||||
return now.getTime() > Number(lastRetryDate)
|
return now.getTime() > Number(lastRetryDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setNextRetryTime = (retryIntervalInSeconds: number) => {
|
const setNextRetryTime = (retryIntervalInSeconds: number) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
|
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000
|
||||||
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
|
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
const initiateSilentLogin = () => {
|
const initiateSilentLogin = () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
|
|||||||
name="roomId"
|
name="roomId"
|
||||||
label={t('joinInputLabel')}
|
label={t('joinInputLabel')}
|
||||||
description={t('joinInputExample', {
|
description={t('joinInputExample', {
|
||||||
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf',
|
||||||
})}
|
})}
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
return !isRoomValid(value.trim()) ? (
|
return !isRoomValid(value.trim()) ? (
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
|
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
||||||
|
|
||||||
|
// fixme - duplication with the InviteDialog
|
||||||
|
export const LaterMeetingDialog = ({
|
||||||
|
roomId,
|
||||||
|
...dialogProps
|
||||||
|
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
const roomUrl = getRouteUrl('room', roomId)
|
||||||
|
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCopied) {
|
||||||
|
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [isCopied])
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={!!roomId}
|
||||||
|
{...dialogProps}
|
||||||
|
title={t('laterMeetingDialog.heading')}
|
||||||
|
>
|
||||||
|
<P>{t('laterMeetingDialog.description')}</P>
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'primary'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={t('laterMeetingDialog.copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
navigator.clipboard.writeText(roomUrl)
|
||||||
|
setIsCopied(true)
|
||||||
|
}}
|
||||||
|
onHoverChange={setIsHovered}
|
||||||
|
data-attr="later-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('laterMeetingDialog.copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{isHovered ? (
|
||||||
|
t('laterMeetingDialog.copy')
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
textWrap: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#d9e5ff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
|
||||||
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('laterMeetingDialog.permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger } from 'react-aria-components'
|
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
import { Button, Div, Text, VerticallyOffCenter } from '@/primitives'
|
import { Button, Menu, Text } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { generateRoomId } from '@/features/rooms'
|
|
||||||
import { authUrl, useUser } from '@/features/auth'
|
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Centered } from '@/layout/Centered'
|
||||||
|
import { generateRoomId } from '@/features/rooms'
|
||||||
|
import { useUser, UserAware } from '@/features/auth'
|
||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
|
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||||
|
import { useCreateRoom } from '@/features/rooms'
|
||||||
|
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn } = useUser()
|
||||||
|
|
||||||
|
const {
|
||||||
|
userChoices: { username },
|
||||||
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<UserAware>
|
||||||
<VerticallyOffCenter>
|
<Screen>
|
||||||
<Div margin="auto" width="fit-content">
|
<Centered width="fit-content">
|
||||||
<Text as="h1" variant="display">
|
<Text as="h1" variant="display">
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -26,31 +42,65 @@ export const Home = () => {
|
|||||||
{t('loginToCreateMeeting')}
|
{t('loginToCreateMeeting')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<HStack gap="gutter">
|
<HStack gap="gutter" alignItems="start">
|
||||||
<Button
|
{isLoggedIn ? (
|
||||||
variant="primary"
|
<Menu>
|
||||||
onPress={
|
<Button variant="primary" data-attr="create-meeting">
|
||||||
isLoggedIn
|
{t('createMeeting')}
|
||||||
? () =>
|
</Button>
|
||||||
navigateTo('room', generateRoomId(), {
|
<RACMenu>
|
||||||
state: { create: true },
|
<MenuItem
|
||||||
})
|
className={menuItemRecipe({ icon: true })}
|
||||||
: undefined
|
onAction={async () => {
|
||||||
}
|
const slug = generateRoomId()
|
||||||
href={isLoggedIn ? undefined : authUrl()}
|
createRoom({ slug, username }).then((data) =>
|
||||||
>
|
navigateTo('room', data.slug, {
|
||||||
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
|
state: { create: true, initialRoomData: data },
|
||||||
</Button>
|
})
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
data-attr="create-option-instant"
|
||||||
|
>
|
||||||
|
<RiAddLine size={18} />
|
||||||
|
{t('createMenu.instantOption')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={() => {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
createRoom({ slug, username }).then((data) =>
|
||||||
|
setLaterRoomId(data.slug)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
data-attr="create-option-later"
|
||||||
|
>
|
||||||
|
<RiLink size={18} />
|
||||||
|
{t('createMenu.laterOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<ProConnectButton />
|
||||||
|
)}
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="primary" outline>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
outline
|
||||||
|
style={{
|
||||||
|
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('joinMeeting')}
|
{t('joinMeeting')}
|
||||||
</Button>
|
</Button>
|
||||||
<JoinMeetingDialog />
|
<JoinMeetingDialog />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Div>
|
</Centered>
|
||||||
</VerticallyOffCenter>
|
<LaterMeetingDialog
|
||||||
</Screen>
|
roomId={laterRoomId || ''}
|
||||||
|
onOpenChange={() => setLaterRoomId(null)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
</UserAware>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Participant, RoomEvent } from 'livekit-client'
|
||||||
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
|
import { NotificationType } from './NotificationType'
|
||||||
|
import { Div } from '@/primitives'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||||
|
|
||||||
|
export const MainNotificationToast = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showJoinNotification = (participant: Participant) => {
|
||||||
|
if (isMobileBrowser()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
triggerNotificationSound(NotificationType.Joined)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
type: NotificationType.Joined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 5000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||||
|
}
|
||||||
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeParticipantNotifications = (participant: Participant) => {
|
||||||
|
toastQueue.visibleToasts.forEach((toast) => {
|
||||||
|
if (toast.content.participant === participant) {
|
||||||
|
toastQueue.close(toast.key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
|
||||||
|
return () => {
|
||||||
|
room.off(
|
||||||
|
RoomEvent.ParticipantDisconnected,
|
||||||
|
removeParticipantNotifications
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNotificationReceived = (
|
||||||
|
prevMetadataStr: string | undefined,
|
||||||
|
participant: Participant
|
||||||
|
) => {
|
||||||
|
if (!participant) return
|
||||||
|
if (isMobileBrowser()) return
|
||||||
|
if (participant.isLocal) return
|
||||||
|
|
||||||
|
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
|
||||||
|
const metadata = JSON.parse(participant.metadata || '{}')
|
||||||
|
|
||||||
|
if (prevMetadata.raised == metadata.raised) return
|
||||||
|
|
||||||
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
|
(toast) =>
|
||||||
|
toast.content.participant === participant &&
|
||||||
|
toast.content.type === NotificationType.Raised
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||||
|
toastQueue.close(existingToast.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||||
|
triggerNotificationSound(NotificationType.Raised)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
type: NotificationType.Raised,
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||||
|
}
|
||||||
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeAllToasts = () => {
|
||||||
|
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.Disconnected, closeAllToasts)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Disconnected, closeAllToasts)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||||
|
<ToastProvider />
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum NotificationType {
|
||||||
|
Joined = 'joined',
|
||||||
|
Default = 'default',
|
||||||
|
Raised = 'raised',
|
||||||
|
Lowered = 'lowered',
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { ToastState } from '@react-stately/toast'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { ToastData } from './ToastProvider'
|
||||||
|
import type { QueuedToast } from '@react-stately/toast'
|
||||||
|
|
||||||
|
export const StyledToastContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
margin: 0.5,
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
|
||||||
|
backgroundColor: '#494c4f',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
'&[data-entering]': { animation: 'fade 200ms' },
|
||||||
|
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||||
|
width: 'fit-content',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledToast = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '10px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
key: string
|
||||||
|
toast: QueuedToast<ToastData>
|
||||||
|
state: ToastState<ToastData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({ state, ...props }: ToastProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<StyledToast>
|
||||||
|
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||||
|
<Button square size="sm" invisible {...closeButtonProps}>
|
||||||
|
<RiCloseLine color="white" />
|
||||||
|
</Button>
|
||||||
|
</StyledToast>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import Source = Track.Source
|
||||||
|
|
||||||
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { StyledToastContainer, ToastProps } from './Toast'
|
||||||
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
|
import { Div } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const ClickableToast = styled(RACButton, {
|
||||||
|
base: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ToastJoined({ state, ...props }: ToastProps) {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
const participant = props.toast.content.participant
|
||||||
|
const trackReference = {
|
||||||
|
participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
const pinParticipant = () => {
|
||||||
|
layoutContext?.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<ClickableToast
|
||||||
|
ref={ref}
|
||||||
|
onPress={(e) => {
|
||||||
|
pinParticipant()
|
||||||
|
closeButtonProps.onPress?.(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack justify="center" alignItems="center" {...contentProps}>
|
||||||
|
<Div display="flex" overflow="hidden" width="128" height="72">
|
||||||
|
<ParticipantTile
|
||||||
|
trackRef={trackReference}
|
||||||
|
disableSpeakingIndicator={true}
|
||||||
|
disableMetadata={true}
|
||||||
|
style={{
|
||||||
|
borderRadius: '7px 0 0 7px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
<Div padding={20} {...titleProps}>
|
||||||
|
{t('joined.description', {
|
||||||
|
name: participant.name || t('defaultName'),
|
||||||
|
})}
|
||||||
|
</Div>
|
||||||
|
</HStack>
|
||||||
|
</ClickableToast>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
||||||
|
import { ToastRegion } from './ToastRegion'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|
||||||
|
export interface ToastData {
|
||||||
|
participant: Participant
|
||||||
|
type: NotificationType
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using a global queue for toasts allows for centralized management and queuing of notifications
|
||||||
|
// from anywhere in the app, providing greater flexibility in complex scenarios.
|
||||||
|
export const toastQueue = new ToastQueue<ToastData>({
|
||||||
|
maxVisibleToasts: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ToastProvider = ({ ...props }) => {
|
||||||
|
const state = useToastQueue<ToastData>(toastQueue)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.visibleToasts.length > 0 && (
|
||||||
|
<ToastRegion {...props} state={state} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
import { StyledToastContainer, ToastProps } from './Toast'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button, Div } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
|
||||||
|
export function ToastRaised({ state, ...props }: ToastProps) {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
const participant = props.toast.content.participant
|
||||||
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<HStack
|
||||||
|
justify="center"
|
||||||
|
alignItems="center"
|
||||||
|
{...contentProps}
|
||||||
|
padding={14}
|
||||||
|
gap={0}
|
||||||
|
>
|
||||||
|
<RiHand
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
marginRight: '1rem',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Div {...titleProps} marginRight={0.5}>
|
||||||
|
{t('raised.description', {
|
||||||
|
name: participant.name || t('defaultName'),
|
||||||
|
})}
|
||||||
|
</Div>
|
||||||
|
{!isParticipantsOpen && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
style={{
|
||||||
|
color: '#60a5fa',
|
||||||
|
}}
|
||||||
|
onPress={(e) => {
|
||||||
|
toggleParticipants()
|
||||||
|
closeButtonProps.onPress?.(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('raised.cta')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button square size="sm" invisible {...closeButtonProps}>
|
||||||
|
<RiCloseLine size={18} color="white" />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||||
|
import type { ToastState } from '@react-stately/toast'
|
||||||
|
import { Toast } from './Toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { NotificationType } from '../NotificationType'
|
||||||
|
import { ToastJoined } from './ToastJoined'
|
||||||
|
import { ToastData } from './ToastProvider'
|
||||||
|
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||||
|
|
||||||
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
|
state: ToastState<ToastData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { regionProps } = useToastRegion(props, state, ref)
|
||||||
|
return (
|
||||||
|
<div {...regionProps} ref={ref} className="toast-region">
|
||||||
|
{state.visibleToasts.map((toast) => {
|
||||||
|
if (toast.content?.type === NotificationType.Joined) {
|
||||||
|
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||||
|
}
|
||||||
|
if (toast.content?.type === NotificationType.Raised) {
|
||||||
|
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||||
|
}
|
||||||
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import useSound from 'use-sound'
|
||||||
|
|
||||||
|
// fixme - handle dynamic audio output changes
|
||||||
|
export const useNotificationSound = () => {
|
||||||
|
const [play] = useSound('./sounds/notifications.mp3', {
|
||||||
|
sprite: {
|
||||||
|
joined: [0, 1150],
|
||||||
|
raised: [1400, 180],
|
||||||
|
message: [1580, 300],
|
||||||
|
waiting: [2039, 710],
|
||||||
|
success: [2740, 1304],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const triggerNotificationSound = (type: string) => {
|
||||||
|
play({ id: type })
|
||||||
|
}
|
||||||
|
return { triggerNotificationSound }
|
||||||
|
}
|
||||||
@@ -8,4 +8,7 @@ export type ApiRoom = {
|
|||||||
room: string
|
room: string
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
configuration?: {
|
||||||
|
[key: string]: string | number | boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user