mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
26 Commits
fix-warnin
...
new-ui/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e78b542f | ||
|
|
e9a66dd1b3 | ||
|
|
572a26d1bb | ||
|
|
1260310199 | ||
|
|
9e900f5b44 | ||
|
|
5363a1328e | ||
|
|
33986a6bef | ||
|
|
b8c0c40693 | ||
|
|
bdcb337d0d | ||
|
|
fb22e24a1a | ||
|
|
c21ef3e85c | ||
|
|
194cf2b90f | ||
|
|
8524f59deb | ||
|
|
d92a8c6260 | ||
|
|
94a65da8ca | ||
|
|
c782b05594 | ||
|
|
25e76a76b7 | ||
|
|
2ab911426c | ||
|
|
1049033917 | ||
|
|
de7a601d8b | ||
|
|
b361700638 | ||
|
|
5295abade8 | ||
|
|
4fa7a367a9 | ||
|
|
b00dedf74e | ||
|
|
2bed0cab93 | ||
|
|
b8d8f1830e |
91
.github/workflows/docker-hub.yml
vendored
91
.github/workflows/docker-hub.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'main-new-ui'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
@@ -19,9 +20,26 @@ jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -31,7 +49,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.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
|
||||
@@ -53,9 +71,26 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -65,7 +100,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.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
|
||||
@@ -88,9 +123,26 @@ jobs:
|
||||
build-and-push-y-provider:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -100,12 +152,12 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.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/servers/y-provider/Dockerfile --target y-provider'
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
@@ -113,7 +165,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -128,12 +180,29 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
|
||||
24
.github/workflows/helmfile-linter.yaml
vendored
24
.github/workflows/helmfile-linter.yaml
vendored
@@ -2,7 +2,6 @@ name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
@@ -13,18 +12,11 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Helmfile lint
|
||||
shell: bash
|
||||
run: |
|
||||
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 $HELMFILE lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
-
|
||||
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: "impress,secrets"
|
||||
|
||||
41
.github/workflows/impress-frontend.yml
vendored
41
.github/workflows/impress-frontend.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -46,11 +46,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
@@ -59,7 +54,7 @@ jobs:
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn test
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,33 +90,11 @@ jobs:
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
printf "Minio check...\n"
|
||||
dockerize -wait tcp://localhost:9000 -timeout 20s
|
||||
printf "Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:8080 -timeout 20s
|
||||
printf "Server collaboration check...\n"
|
||||
dockerize -wait tcp://localhost:4444 -timeout 20s
|
||||
printf "Ngnix check...\n"
|
||||
dockerize -wait tcp://localhost:8083 -timeout 20s
|
||||
printf "DRF check...\n"
|
||||
dockerize -wait tcp://localhost:8071 -timeout 20s
|
||||
printf "Postgres Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:5433 -timeout 20s
|
||||
printf "Postgres back check...\n"
|
||||
dockerize -wait tcp://localhost:15432 -timeout 20s
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
@@ -151,12 +124,12 @@ jobs:
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||
|
||||
|
||||
34
.github/workflows/release-helm-chart.yaml
vendored
34
.github/workflows/release-helm-chart.yaml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- src/helm/impress/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -0,0 +1,3 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -10,72 +10,21 @@ and this project adheres to
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
|
||||
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
||||
- ✨(frontend) WIP: New ui
|
||||
- 💄(frontend) Add left panel #420
|
||||
- 💄(frontend) add filtering to left panel #475
|
||||
- ✨(frontend) new share modal ui #489
|
||||
- ✨(frontend) add favorite feature #515
|
||||
|
||||
## Changed
|
||||
|
||||
- 🏗️(yjs-server) organize yjs server #528
|
||||
- ♻️(frontend) better separation collaboration process #528
|
||||
- 💄(frontend) updating the header and leftpanel for responsive #421
|
||||
- 💄(frontend) update DocsGrid component #431
|
||||
- 💄(frontend) update DocsGridOptions component #432
|
||||
- 💄(frontend) update DocHeader ui #446
|
||||
- 💄(frontend) update doc versioning ui #463
|
||||
- 💄(frontend) update doc summary ui #473
|
||||
|
||||
|
||||
## [1.10.0] - 2024-12-17
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) add server-to-server API endpoint to create documents #467
|
||||
- ✨(email) white brand email #412
|
||||
- ✨(y-provider) create a markdown converter endpoint #488
|
||||
|
||||
## Changed
|
||||
|
||||
- ⚡️(docker) improve y-provider image #422
|
||||
|
||||
## Fixed
|
||||
|
||||
- ⚡️(e2e) reduce flakiness on e2e tests #511
|
||||
|
||||
|
||||
## Fixed
|
||||
- 🐛(frontend) fix doc grid button #478
|
||||
- 💄(frontend) update doc home left panel #475
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
|
||||
## [1.9.0] - 2024-12-11
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) annotate number of accesses on documents in list view #429
|
||||
- ✨(backend) allow users to mark/unmark documents as favorite #429
|
||||
|
||||
## Changed
|
||||
|
||||
- 🔒️(collaboration) increase collaboration access security #472
|
||||
- 🔨(frontend) encapsulated title to its own component #474
|
||||
- ⚡️(backend) optimize number of queries on document list view #429
|
||||
- ♻️(frontend) stop to use provider with version #480
|
||||
- 🚚(collaboration) change the websocket key name #480
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix initial content with collaboration #484
|
||||
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||
- 🐛(backend) fix sanitize problem IA #490
|
||||
|
||||
|
||||
## [1.8.2] - 2024-11-28
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(SW) change strategy html caching #460
|
||||
@@ -92,17 +41,23 @@ and this project adheres to
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) annotate number of accesses on documents in list view #411
|
||||
- ✨(backend) allow users to mark/unmark documents as favorite #411
|
||||
- 🌐(backend) add German translation #259
|
||||
- 🌐(frontend) add German translation #255
|
||||
- ✨(frontend) add a broadcast store #387
|
||||
- ✨(backend) config endpoint #425
|
||||
- 💄(frontend) update DocsGrid component #431
|
||||
- ✨(backend) whitelist pod's IP address #443
|
||||
- ✨(backend) config endpoint #425
|
||||
- ✨(frontend) config endpoint #424
|
||||
- ✨(frontend) add sentry #424
|
||||
- ✨(frontend) add crisp chatbot #450
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
- ⚡️(backend) optimize number of queries on document list view #411
|
||||
- 🚸(backend) improve users similarity search and sort results #391
|
||||
- ♻️(frontend) simplify stores #402
|
||||
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
|
||||
@@ -197,7 +152,7 @@ and this project adheres to
|
||||
|
||||
- 🛂(frontend) match email if no existing user matches the sub
|
||||
- 🐛(backend) gitlab oicd userinfo endpoint #232
|
||||
- 🛂(frontend) redirect to the OIDC when private doc and unauthenticated #292
|
||||
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
|
||||
- ♻️(backend) getting list of document versions available for a user #258
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
@@ -326,7 +281,7 @@ and this project adheres to
|
||||
- ⚡️(e2e) unique login between tests (#80)
|
||||
- ⚡️(CI) improve e2e job (#86)
|
||||
- ♻️(frontend) improve the error and message info ui (#93)
|
||||
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -346,9 +301,7 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
|
||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.2...main
|
||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
||||
|
||||
2
Makefile
2
Makefile
@@ -122,8 +122,8 @@ logs: ## display app-dev logs (follow mode)
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
|
||||
@@ -20,7 +20,7 @@ docker_build(
|
||||
docker_build(
|
||||
'localhost:5001/impress-y-provider:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
live_update=[
|
||||
|
||||
@@ -1,2 +1,103 @@
|
||||
#!/bin/sh
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
echo "0. Create ca"
|
||||
# 0. Create ca
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
echo "1. Create registry container unless it already exists"
|
||||
# 1. Create registry container unless it already exists
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
# 3. Add the registry config to the nodes
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
echo "5. Document the local registry"
|
||||
# 5. Document the local registry
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
echo "6. Install ingress-nginx"
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
|
||||
@@ -118,7 +118,6 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
@@ -159,13 +158,15 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: y-provider
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
ports:
|
||||
- "4444:4444"
|
||||
volumes:
|
||||
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
|
||||
- /home/frontend/servers/y-provider/node_modules/
|
||||
- /home/frontend/servers/y-provider/dist/
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
|
||||
@@ -4,58 +4,9 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for collaboration server
|
||||
location /collaboration/ws/ {
|
||||
# Collaboration Auth request configuration
|
||||
auth_request /collaboration-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $canEdit $upstream_http_x_can_edit;
|
||||
auth_request_set $userId $upstream_http_x_user_id;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Can-Edit $canEdit;
|
||||
proxy_set_header X-User-Id $userId;
|
||||
|
||||
# Ensure WebSocket upgrade
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
# Collaboration server
|
||||
proxy_pass http://y-provider:4444;
|
||||
|
||||
# Set appropriate timeout for WebSocket
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
|
||||
# Preserve original host and additional headers
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /collaboration-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location /collaboration/api/ {
|
||||
# Collaboration server
|
||||
proxy_pass http://y-provider:4444;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /media-auth;
|
||||
auth_request /auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
@@ -70,8 +21,8 @@ server {
|
||||
proxy_set_header Host minio:9000;
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
|
||||
location /auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
image:
|
||||
repository: lasuite/impress-backend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
backend:
|
||||
replicas: 1
|
||||
envVars:
|
||||
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
|
||||
COLLABORATION_SERVER_SECRET: my-secret
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Feature
|
||||
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
|
||||
DJANGO_SECRET_KEY: AgoodOrAbadKey
|
||||
DJANGO_SETTINGS_MODULE: impress.settings
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP: INFO
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: impress
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_VERIFY_SSL: False
|
||||
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
|
||||
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||
DB_HOST: postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: root
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: impress-media-storage
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
|
||||
migrate:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/impress.py"
|
||||
- "impress.wsgi:application"
|
||||
- "--reload"
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
frontend:
|
||||
envVars:
|
||||
PORT: 8080
|
||||
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: lasuite/impress-frontend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
yProvider:
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: lasuite/impress-y-provider
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
envVars:
|
||||
COLLABORATION_LOGGING: true
|
||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_SECRET: my-secret
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
ingressCollaborationWS:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
||||
|
||||
ingressCollaborationApi:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
ingressAdmin:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.impress.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
auth:
|
||||
rootUser: root
|
||||
rootPassword: password
|
||||
provisioning:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: impress-media-storage
|
||||
versioning: true
|
||||
@@ -1,7 +0,0 @@
|
||||
auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: impress
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
@@ -1,4 +0,0 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
- a S3 bucket (if you don't have one, we will provide an example)
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you will need to install:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
|
||||
```
|
||||
./bin/start-kind.sh
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 24 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
2. Create kind cluster with containerd registry config dir enabled
|
||||
Creating cluster "suite" ...
|
||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-suite"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-suite
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/impress created
|
||||
Context "kind-suite" modified.
|
||||
secret/mkcert created
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
|
||||
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
||||
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
||||
```
|
||||
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
```
|
||||
$ kubectl create namespace impress
|
||||
$ kubectl config set-context --current --namespace=impress
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important informations you will need are :
|
||||
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: impress
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connexion values
|
||||
|
||||
Impress need a redis so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 28m
|
||||
keycloak-postgresql-0 1/1 Running 0 28m
|
||||
postgresql-0 1/1 Running 0 14m
|
||||
redis-master-0 1/1 Running 0 42s
|
||||
```
|
||||
|
||||
From here important informations you will need are :
|
||||
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
### Find s3 bucket connexion values
|
||||
|
||||
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 38m
|
||||
keycloak-postgresql-0 1/1 Running 0 38m
|
||||
minio-84f5c66895-bbhsk 1/1 Running 0 42s
|
||||
minio-provisioning-2b5sq 0/1 Completed 0 42s
|
||||
postgresql-0 1/1 Running 0 24m
|
||||
redis-master-0 1/1 Running 0 10m
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||
$ helm repo update
|
||||
$ helm install impress impress/docs -f examples/impress.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
|
||||
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
|
||||
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
|
||||
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
|
||||
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
|
||||
keycloak-0 1/1 Running 0 48m
|
||||
keycloak-postgresql-0 1/1 Running 0 48m
|
||||
minio-84f5c66895-bbhsk 1/1 Running 0 10m
|
||||
minio-provisioning-2b5sq 0/1 Completed 0 10m
|
||||
postgresql-0 1/1 Running 0 34m
|
||||
redis-master-0 1/1 Running 0 20m
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
||||
```
|
||||
|
||||
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
|
||||
@@ -6,7 +6,7 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
|
||||
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 each project (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
|
||||
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -16,9 +16,7 @@ PYTHONPATH=/app
|
||||
# impress settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
@@ -55,10 +53,7 @@ AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
|
||||
# Collaboration
|
||||
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
|
||||
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
||||
COLLABORATION_SERVER_SECRET=my-secret
|
||||
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
|
||||
COLLABORATION_SERVER_URL=ws://localhost:4444
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=dsfr
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
@@ -13,13 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"eslint",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
secrets
Submodule
1
secrets
Submodule
Submodule secrets added at 38594182e8
@@ -4,7 +4,6 @@ import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import magic
|
||||
@@ -12,10 +11,6 @@ from rest_framework import exceptions, serializers
|
||||
|
||||
from core import enums, models
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
YdocConverter,
|
||||
)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -232,96 +227,6 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for creating a document from a server-to-server request.
|
||||
|
||||
Expects 'content' as a markdown string, which is converted to our internal format
|
||||
via a Node.js microservice. The conversion is handled automatically, so third parties
|
||||
only need to provide markdown.
|
||||
|
||||
Both "sub" and "email" are required because the external app calling doesn't know
|
||||
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
|
||||
submitted "email" field and use the email address set on the user account in our database
|
||||
"""
|
||||
|
||||
# Document
|
||||
title = serializers.CharField(required=True)
|
||||
content = serializers.CharField(required=True)
|
||||
# User
|
||||
sub = serializers.CharField(
|
||||
required=True, validators=[models.User.sub_validator], max_length=255
|
||||
)
|
||||
email = serializers.EmailField(required=True)
|
||||
language = serializers.ChoiceField(
|
||||
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
|
||||
)
|
||||
# Invitation
|
||||
message = serializers.CharField(required=False)
|
||||
subject = serializers.CharField(required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create the document and associate it with the user or send an invitation."""
|
||||
language = validated_data.get("language", settings.LANGUAGE_CODE)
|
||||
|
||||
# Get the user based on the sub (unique identifier)
|
||||
try:
|
||||
user = models.User.objects.get(sub=validated_data["sub"])
|
||||
except (models.User.DoesNotExist, KeyError):
|
||||
user = None
|
||||
email = validated_data["email"]
|
||||
else:
|
||||
email = user.email
|
||||
language = user.language or language
|
||||
|
||||
try:
|
||||
document_content = YdocConverter().convert_markdown(
|
||||
validated_data["content"]
|
||||
)
|
||||
except ConversionError as err:
|
||||
raise exceptions.APIException(detail="could not convert content") from err
|
||||
|
||||
document = models.Document.objects.create(
|
||||
title=validated_data["title"],
|
||||
content=document_content,
|
||||
creator=user,
|
||||
)
|
||||
|
||||
if user:
|
||||
# Associate the document with the pre-existing user
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
role=models.RoleChoices.OWNER,
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
# The user doesn't exist in our database: we need to invite him/her
|
||||
models.Invitation.objects.create(
|
||||
document=document,
|
||||
email=email,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
# Notify the user about the newly created document
|
||||
subject = validated_data.get("subject") or _(
|
||||
"A new document was created on your behalf!"
|
||||
)
|
||||
context = {
|
||||
"message": validated_data.get("message")
|
||||
or _("You have been granted ownership of a new document:"),
|
||||
"title": subject,
|
||||
}
|
||||
document.send_email(subject, [email], context, language)
|
||||
|
||||
return document
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
This serializer does not support updates.
|
||||
"""
|
||||
raise NotImplementedError("Update is not supported for this serializer.")
|
||||
|
||||
|
||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
||||
"""
|
||||
Serialize link configuration for documents.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
@@ -11,10 +10,10 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models as db
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Exists,
|
||||
Min,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
@@ -22,37 +21,48 @@ from django.db.models import (
|
||||
)
|
||||
from django.http import Http404
|
||||
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import filters, status
|
||||
from rest_framework import response as drf_response
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
metadata,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
views,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
filters as drf_filters,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import authentication, enums, models
|
||||
from core import enums, models
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ATTACHMENTS_FOLDER = "attachments"
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
|
||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
|
||||
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
MEDIA_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
|
||||
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
ATTACHMENTS_FOLDER = "attachments"
|
||||
|
||||
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
@@ -124,7 +134,7 @@ class SerializerPerActionMixin:
|
||||
return self.serializer_classes.get(self.action, self.default_serializer_class)
|
||||
|
||||
|
||||
class Pagination(drf.pagination.PageNumberPagination):
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
@@ -133,14 +143,13 @@ class Pagination(drf.pagination.PageNumberPagination):
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -175,7 +184,7 @@ class UserViewSet(
|
||||
|
||||
return queryset
|
||||
|
||||
@drf.decorators.action(
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
@@ -187,7 +196,7 @@ class UserViewSet(
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
@@ -222,7 +231,7 @@ class ResourceAccessViewsetMixin:
|
||||
teams = user.teams
|
||||
user_roles_query = (
|
||||
queryset.filter(
|
||||
db.Q(user=user) | db.Q(team__in=teams),
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
@@ -236,13 +245,11 @@ class ResourceAccessViewsetMixin:
|
||||
# access instances pointing to the logged-in user)
|
||||
queryset = (
|
||||
queryset.filter(
|
||||
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
|
||||
| db.Q(
|
||||
**{f"{self.resource_field_name}__accesses__team__in": teams}
|
||||
),
|
||||
Q(**{f"{self.resource_field_name}__accesses__user": user})
|
||||
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
)
|
||||
.annotate(user_roles=db.Subquery(user_roles_query))
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
@@ -257,9 +264,9 @@ class ResourceAccessViewsetMixin:
|
||||
instance.role == "owner"
|
||||
and resource.accesses.filter(role="owner").count() == 1
|
||||
):
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{"detail": "Cannot delete the last owner access for the resource."},
|
||||
status=drf.status.HTTP_403_FORBIDDEN,
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -280,12 +287,12 @@ class ResourceAccessViewsetMixin:
|
||||
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
|
||||
):
|
||||
message = "Cannot change the role to a non-owner role for the last owner access."
|
||||
raise drf.exceptions.PermissionDenied({"detail": message})
|
||||
raise exceptions.PermissionDenied({"detail": message})
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class DocumentMetadata(drf.metadata.SimpleMetadata):
|
||||
class DocumentMetadata(metadata.SimpleMetadata):
|
||||
"""Custom metadata class to add information"""
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
@@ -303,10 +310,10 @@ class DocumentMetadata(drf.metadata.SimpleMetadata):
|
||||
|
||||
|
||||
class DocumentViewSet(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Document ViewSet for managing documents.
|
||||
@@ -326,7 +333,7 @@ class DocumentViewSet(
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
filter_backends = [filters.DjangoFilterBackend, drf_filters.OrderingFilter]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
@@ -382,11 +389,11 @@ class DocumentViewSet(
|
||||
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
| (
|
||||
db.Q(link_traces__user=user)
|
||||
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
Q(link_traces__user=user)
|
||||
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -398,7 +405,7 @@ class DocumentViewSet(
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -421,7 +428,7 @@ class DocumentViewSet(
|
||||
# The trace already exists, so we just pass without doing anything
|
||||
pass
|
||||
|
||||
return drf.response.Response(serializer.data)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator and owner of the newly created object."""
|
||||
@@ -432,31 +439,7 @@ class DocumentViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
authentication_classes=[authentication.ServerToServerAuthentication],
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
permission_classes=[],
|
||||
url_path="create-for-owner",
|
||||
)
|
||||
def create_for_owner(self, request):
|
||||
"""
|
||||
Create a document on behalf of a specified owner (pre-existing user or invited).
|
||||
"""
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
document = serializer.save()
|
||||
|
||||
return drf_response.Response(
|
||||
{"id": str(document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return the document's versions but only those created after the user got access
|
||||
@@ -464,7 +447,7 @@ class DocumentViewSet(
|
||||
"""
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
raise drf.exceptions.PermissionDenied("Authentication required.")
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
|
||||
# Validate query parameters using dedicated serializer
|
||||
serializer = serializers.VersionFilterSerializer(data=request.query_params)
|
||||
@@ -475,13 +458,13 @@ class DocumentViewSet(
|
||||
# Users should not see version history dating from before they gained access to the
|
||||
# document. Filter to get the minimum access date for the logged-in user
|
||||
access_queryset = document.accesses.filter(
|
||||
db.Q(user=user) | db.Q(team__in=user.teams)
|
||||
).aggregate(min_date=db.Min("created_at"))
|
||||
Q(user=user) | Q(team__in=user.teams)
|
||||
).aggregate(min_date=Min("created_at"))
|
||||
|
||||
# Handle the case where the user has no accesses
|
||||
min_datetime = access_queryset["min_date"]
|
||||
if not min_datetime:
|
||||
return drf.exceptions.PermissionDenied(
|
||||
return exceptions.PermissionDenied(
|
||||
"Only users with specific access can see version history"
|
||||
)
|
||||
|
||||
@@ -491,9 +474,9 @@ class DocumentViewSet(
|
||||
page_size=serializer.validated_data.get("page_size"),
|
||||
)
|
||||
|
||||
return drf.response.Response(versions_data)
|
||||
return drf_response.Response(versions_data)
|
||||
|
||||
@drf.decorators.action(
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
|
||||
@@ -514,7 +497,7 @@ class DocumentViewSet(
|
||||
min_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
db.Q(user=user) | db.Q(team__in=user.teams),
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
if response["LastModified"] < min_datetime:
|
||||
@@ -522,11 +505,11 @@ class DocumentViewSet(
|
||||
|
||||
if request.method == "DELETE":
|
||||
response = document.delete_version(version_id)
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
status=response["ResponseMetadata"]["HTTPStatusCode"]
|
||||
)
|
||||
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{
|
||||
"content": response["Body"].read().decode("utf-8"),
|
||||
"last_modified": response["LastModified"],
|
||||
@@ -534,7 +517,7 @@ class DocumentViewSet(
|
||||
}
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
|
||||
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
|
||||
def link_configuration(self, request, *args, **kwargs):
|
||||
"""Update link configuration with specific rights (cf get_abilities)."""
|
||||
# Check permissions first
|
||||
@@ -547,13 +530,9 @@ class DocumentViewSet(
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
serializer.save()
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Notify collaboration server about the link updated
|
||||
CollaborationService().reset_connections(str(document.id))
|
||||
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
|
||||
@decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
|
||||
def favorite(self, request, *args, **kwargs):
|
||||
"""
|
||||
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
|
||||
@@ -567,13 +546,13 @@ class DocumentViewSet(
|
||||
try:
|
||||
models.DocumentFavorite.objects.create(document=document, user=user)
|
||||
except ValidationError:
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{"detail": "Document already marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{"detail": "Document marked as favorite"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# Handle DELETE method to unmark as favorite
|
||||
@@ -581,16 +560,16 @@ class DocumentViewSet(
|
||||
document=document, user=user
|
||||
).delete()
|
||||
if deleted:
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{"detail": "Document unmarked as favorite"},
|
||||
status=drf.status.HTTP_204_NO_CONTENT,
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
return drf.response.Response(
|
||||
return drf_response.Response(
|
||||
{"detail": "Document was already not marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
|
||||
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
|
||||
def attachment_upload(self, request, *args, **kwargs):
|
||||
"""Upload a file related to a given document"""
|
||||
# Check permissions first
|
||||
@@ -615,128 +594,53 @@ class DocumentViewSet(
|
||||
file, default_storage.bucket_name, key, ExtraArgs=extra_args
|
||||
)
|
||||
|
||||
return drf.response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
return drf_response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
|
||||
def retrieve_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
Shared method to authorize access based on the original URL of an Nginx subrequest
|
||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||
This view is used by an Nginx subrequest to control access to a document's
|
||||
attachment file.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
|
||||
Parameters:
|
||||
- pattern: The regex pattern to extract identifiers from the URL.
|
||||
|
||||
Returns:
|
||||
- A dictionary of URL parameters if the request is authorized.
|
||||
Raises:
|
||||
- PermissionDenied if authorization fails.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
parsed_url = urlparse(original_url)
|
||||
match = pattern.search(parsed_url.path)
|
||||
|
||||
# If the path does not match the pattern, try to extract the parameters from the query
|
||||
if not match:
|
||||
match = pattern.search(parsed_url.query)
|
||||
|
||||
if not match:
|
||||
logger.debug(
|
||||
"Subrequest URL '%s' did not match pattern '%s'",
|
||||
parsed_url.path,
|
||||
pattern,
|
||||
)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
try:
|
||||
url_params = match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf.exceptions.PermissionDenied() from exc
|
||||
|
||||
pk = url_params.get("pk")
|
||||
if not pk:
|
||||
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
# Fetch the document and check if the user has access
|
||||
try:
|
||||
document, _created = models.Document.objects.get_or_create(pk=pk)
|
||||
except models.Document.DoesNotExist as exc:
|
||||
logger.debug("Document with ID '%s' does not exist", pk)
|
||||
raise drf.exceptions.PermissionDenied() from exc
|
||||
|
||||
user_abilities = document.get_abilities(request.user)
|
||||
|
||||
if not user_abilities.get(self.action, False):
|
||||
logger.debug(
|
||||
"User '%s' lacks permission for document '%s'", request.user, pk
|
||||
)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
logger.debug(
|
||||
"Subrequest authorization successful. Extracted parameters: %s", url_params
|
||||
)
|
||||
return url_params, user_abilities, request.user.id
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a document's
|
||||
attachment file.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
url_params, _, _ = self._authorize_subrequest(
|
||||
request, MEDIA_STORAGE_URL_PATTERN
|
||||
)
|
||||
pk, key = url_params.values()
|
||||
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
|
||||
match = MEDIA_URL_PATTERN.search(original_url.path)
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
|
||||
try:
|
||||
pk, attachment_key = match.groups()
|
||||
except AttributeError as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
|
||||
return drf.response.Response("authorized", headers=request.headers, status=200)
|
||||
# Check permission
|
||||
try:
|
||||
document = models.Document.objects.get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
|
||||
def collaboration_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a document's
|
||||
collaboration server.
|
||||
"""
|
||||
_, user_abilities, user_id = self._authorize_subrequest(
|
||||
request, COLLABORATION_WS_URL_PATTERN
|
||||
)
|
||||
can_edit = user_abilities["partial_update"]
|
||||
if not document.get_abilities(request.user).get("retrieve", False):
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
# Add the collaboration server secret token to the headers
|
||||
headers = {
|
||||
"Authorization": settings.COLLABORATION_SERVER_SECRET,
|
||||
"X-Can-Edit": str(can_edit),
|
||||
"X-User-Id": str(user_id),
|
||||
}
|
||||
# Generate authorization headers and return an authorization to proceed with the request
|
||||
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
return drf.response.Response("authorized", headers=headers, status=200)
|
||||
|
||||
@drf.decorators.action(
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Apply a transformation action on a piece of text with AI",
|
||||
@@ -762,9 +666,9 @@ class DocumentViewSet(
|
||||
|
||||
response = AIService().transform(text, action)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
@drf.decorators.action(
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Translate a piece of text with AI",
|
||||
@@ -791,17 +695,17 @@ class DocumentViewSet(
|
||||
|
||||
response = AIService().translate(text, language)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with document accesses.
|
||||
@@ -835,50 +739,28 @@ class DocumentAccessViewSet(
|
||||
serializer_class = serializers.DocumentAccessSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Add new access to the document and email the new added user."""
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
access = serializer.save()
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
access.document.send_invitation_email(
|
||||
access.document.email_invitation(
|
||||
language,
|
||||
access.user.email,
|
||||
access.role,
|
||||
self.request.user,
|
||||
language,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update access to the document and notify the collaboration server."""
|
||||
access = serializer.save()
|
||||
|
||||
access_user_id = None
|
||||
if access.user:
|
||||
access_user_id = str(access.user.id)
|
||||
|
||||
# Notify collaboration server about the access change
|
||||
CollaborationService().reset_connections(
|
||||
str(access.document.id), access_user_id
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete access to the document and notify the collaboration server."""
|
||||
instance.delete()
|
||||
|
||||
# Notify collaboration server about the access removed
|
||||
CollaborationService().reset_connections(
|
||||
str(instance.document.id), str(instance.user.id)
|
||||
)
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
|
||||
filter_backends = [drf.filters.OrderingFilter]
|
||||
filter_backends = [drf_filters.OrderingFilter]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.AccessPermission,
|
||||
@@ -913,9 +795,9 @@ class TemplateViewSet(
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
| db.Q(is_public=True)
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
| Q(is_public=True)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
@@ -926,7 +808,7 @@ class TemplateViewSet(
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
@@ -937,7 +819,7 @@ class TemplateViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
@@ -960,8 +842,8 @@ class TemplateViewSet(
|
||||
serializer = serializers.DocumentGenerationSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf.response.Response(
|
||||
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
body = serializer.validated_data["body"]
|
||||
@@ -974,12 +856,12 @@ class TemplateViewSet(
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with template accesses.
|
||||
@@ -1014,12 +896,12 @@ class TemplateAccessViewSet(
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to document.
|
||||
|
||||
@@ -1071,7 +953,7 @@ class InvitationViewset(
|
||||
# Determine which role the logged-in user has in the document
|
||||
user_roles_query = (
|
||||
models.DocumentAccess.objects.filter(
|
||||
db.Q(user=user) | db.Q(team__in=teams),
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
document=self.kwargs["resource_id"],
|
||||
)
|
||||
.values("document")
|
||||
@@ -1082,34 +964,34 @@ class InvitationViewset(
|
||||
queryset = (
|
||||
# The logged-in user should be administrator or owner to see its accesses
|
||||
queryset.filter(
|
||||
db.Q(
|
||||
Q(
|
||||
document__accesses__user=user,
|
||||
document__accesses__role__in=models.PRIVILEGED_ROLES,
|
||||
)
|
||||
| db.Q(
|
||||
| Q(
|
||||
document__accesses__team__in=teams,
|
||||
document__accesses__role__in=models.PRIVILEGED_ROLES,
|
||||
),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each document access
|
||||
.annotate(user_roles=db.Subquery(user_roles_query))
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then email the invited user."""
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
invitation.document.send_invitation_email(
|
||||
invitation.email, invitation.role, self.request.user, language
|
||||
invitation.document.email_invitation(
|
||||
language, invitation.email, invitation.role, self.request.user
|
||||
)
|
||||
|
||||
|
||||
class ConfigView(drf.views.APIView):
|
||||
class ConfigView(views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
@@ -1120,7 +1002,7 @@ class ConfigView(drf.views.APIView):
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"COLLABORATION_WS_URL",
|
||||
"COLLABORATION_SERVER_URL",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
@@ -1134,4 +1016,4 @@ class ConfigView(drf.views.APIView):
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
return drf_response.Response(dict_settings)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Custom authentication classes for the Impress core app"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class ServerToServerAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for server-to-server requests.
|
||||
Validates the presence and correctness of the Authorization header.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the server-to-server request by validating the Authorization header.
|
||||
|
||||
This method checks if the Authorization header is present in the request, ensures it
|
||||
contains a valid token with the correct format, and verifies the token against the
|
||||
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
|
||||
or contains an invalid token, an AuthenticationFailed exception is raised.
|
||||
|
||||
Returns:
|
||||
None: If authentication is successful
|
||||
(no user is authenticated for server-to-server requests).
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the Authorization header is missing, malformed,
|
||||
or contains an invalid token.
|
||||
"""
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
raise AuthenticationFailed("Authorization header is missing.")
|
||||
|
||||
# Validate token format and existence
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||
|
||||
# Authentication is successful, but no user is authenticated
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Authentication Backends for the Impress core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -13,8 +11,6 @@ from mozilla_django_oidc.auth import (
|
||||
|
||||
from core.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -63,29 +59,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
return userinfo
|
||||
|
||||
def verify_claims(self, claims):
|
||||
"""
|
||||
Verify the presence of essential claims and the "sub" (which is mandatory as defined
|
||||
by the OIDC specification) to decide if authentication should be allowed.
|
||||
"""
|
||||
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
|
||||
missing_claims = [claim for claim in essential_claims if claim not in claims]
|
||||
|
||||
if missing_claims:
|
||||
logger.error("Missing essential claims: %s", missing_claims)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found."""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
if not self.verify_claims(user_info):
|
||||
raise SuspiciousOperation("Claims verification failed.")
|
||||
|
||||
sub = user_info["sub"]
|
||||
email = user_info.get("email")
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
@@ -98,6 +75,12 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"short_name": short_name,
|
||||
}
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
if user:
|
||||
@@ -118,13 +101,15 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
return User.objects.filter(email=email).first()
|
||||
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
@@ -134,4 +119,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
@@ -14,5 +14,5 @@ urlpatterns = [
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozilla_oidc_urls,
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
|
||||
@@ -19,7 +19,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
skip_postgeneration_save = True
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
@@ -37,8 +36,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
if create and (extracted is True):
|
||||
UserDocumentAccessFactory(user=self, role="owner")
|
||||
|
||||
self.save()
|
||||
|
||||
@factory.post_generation
|
||||
def with_owned_template(self, create, extracted, **kwargs):
|
||||
"""
|
||||
@@ -48,8 +45,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
if create and (extracted is True):
|
||||
UserTemplateAccessFactory(user=self, role="owner")
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create documents"""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
@@ -143,7 +145,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
@@ -159,6 +161,6 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-30 22:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_populate_creator_field_and_make_it_required'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitation',
|
||||
name='issuer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-13 22:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='user',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
|
||||
),
|
||||
]
|
||||
@@ -26,8 +26,8 @@ from django.template.context import Context
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import html, timezone
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
@@ -155,7 +155,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to log in to the admin site
|
||||
# stores the email used by staff users to login to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
@@ -199,7 +199,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_user"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
@@ -240,13 +239,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
for invitation in valid_invitations
|
||||
]
|
||||
)
|
||||
|
||||
# Set creator of documents if not yet set (e.g. documents created via server-to-server API)
|
||||
document_ids = [invitation.document_id for invitation in valid_invitations]
|
||||
Document.objects.filter(id__in=document_ids, creator__isnull=True).update(
|
||||
creator=self
|
||||
)
|
||||
|
||||
valid_invitations.delete()
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
@@ -350,11 +342,7 @@ class Document(BaseModel):
|
||||
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
|
||||
)
|
||||
creator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="documents_created",
|
||||
blank=True,
|
||||
null=True,
|
||||
User, on_delete=models.RESTRICT, related_name="documents_created"
|
||||
)
|
||||
|
||||
_content = None
|
||||
@@ -523,86 +511,65 @@ class Document(BaseModel):
|
||||
is_owner_or_admin = bool(
|
||||
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
is_editor = bool(RoleChoices.EDITOR in roles)
|
||||
can_get = bool(roles)
|
||||
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
|
||||
|
||||
return {
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"accesses_view": has_role,
|
||||
"ai_transform": can_update,
|
||||
"ai_translate": can_update,
|
||||
"attachment_upload": can_update,
|
||||
"collaboration_auth": can_get,
|
||||
"ai_transform": is_owner_or_admin or is_editor,
|
||||
"ai_translate": is_owner_or_admin or is_editor,
|
||||
"attachment_upload": is_owner_or_admin or is_editor,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": RoleChoices.OWNER in roles,
|
||||
"partial_update": can_update,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"retrieve": can_get,
|
||||
"media_auth": can_get,
|
||||
"update": can_update,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_role,
|
||||
"versions_retrieve": has_role,
|
||||
}
|
||||
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
"""Generate and send email from a template."""
|
||||
context = context or {}
|
||||
def email_invitation(self, language, email, role, sender):
|
||||
"""Send email invitation."""
|
||||
|
||||
sender_name = sender.full_name or sender.email
|
||||
domain = Site.objects.get_current().domain
|
||||
language = language or get_language()
|
||||
context.update(
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"document": self,
|
||||
"domain": domain,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/invitation.html", context)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", context)
|
||||
subject = str(subject) # Force translation
|
||||
|
||||
try:
|
||||
try:
|
||||
with override(language):
|
||||
title = _(
|
||||
"%(sender_name)s shared a document with you: %(document)s"
|
||||
) % {
|
||||
"sender_name": sender_name,
|
||||
"document": self.title,
|
||||
}
|
||||
template_vars = {
|
||||
"title": title,
|
||||
"domain": domain,
|
||||
"document": self,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"sender_name": sender_name,
|
||||
"sender_name_email": f"{sender.full_name} ({sender.email})"
|
||||
if sender.full_name
|
||||
else sender.email,
|
||||
"role": RoleChoices(role).label.lower(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
title,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
[email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", emails, exception)
|
||||
|
||||
def send_invitation_email(self, email, role, sender, language=None):
|
||||
"""Method allowing a user to send an email invitation to another user for a document."""
|
||||
language = language or get_language()
|
||||
role = RoleChoices(role).label
|
||||
sender_name = sender.full_name or sender.email
|
||||
sender_name_email = (
|
||||
f"{sender.full_name:s} ({sender.email})"
|
||||
if sender.full_name
|
||||
else sender.email
|
||||
)
|
||||
|
||||
with override(language):
|
||||
context = {
|
||||
"title": _("{name} shared a document with you!").format(
|
||||
name=sender_name
|
||||
),
|
||||
"message": _(
|
||||
'{name} invited you with the role "{role}" on the following document:'
|
||||
).format(name=sender_name_email, role=role.lower()),
|
||||
}
|
||||
subject = _("{name} shared a document with you: {title}").format(
|
||||
name=sender_name, title=self.title
|
||||
)
|
||||
|
||||
self.send_email(subject, [email], context, language)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", email, exception)
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
@@ -696,7 +663,7 @@ class DocumentAccess(BaseAccess):
|
||||
violation_error_message=_("This team is already in this document."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_document_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
@@ -743,15 +710,15 @@ class Template(BaseModel):
|
||||
is_owner_or_admin = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
is_editor = bool(RoleChoices.EDITOR in roles)
|
||||
can_get = self.is_public or bool(roles)
|
||||
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
|
||||
|
||||
return {
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"generate_document": can_get,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
@@ -761,7 +728,7 @@ class Template(BaseModel):
|
||||
"""
|
||||
document_html = weasyprint.HTML(
|
||||
string=DjangoTemplate(self.code).render(
|
||||
Context({"body": html.format_html("{}", body_html), **metadata})
|
||||
Context({"body": html.format_html(body_html), **metadata})
|
||||
)
|
||||
)
|
||||
css = weasyprint.CSS(
|
||||
@@ -780,7 +747,7 @@ class Template(BaseModel):
|
||||
Generate and return a docx document wrapped around the current template
|
||||
"""
|
||||
template_string = DjangoTemplate(self.code).render(
|
||||
Context({"body": html.format_html("{}", body_html), **metadata})
|
||||
Context({"body": html.format_html(body_html), **metadata})
|
||||
)
|
||||
|
||||
html_string = f"""
|
||||
@@ -798,6 +765,7 @@ class Template(BaseModel):
|
||||
"""
|
||||
|
||||
reference_docx = "core/static/reference.docx"
|
||||
output = BytesIO()
|
||||
|
||||
# Convert the HTML to a temporary docx file
|
||||
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
|
||||
@@ -884,7 +852,7 @@ class TemplateAccess(BaseAccess):
|
||||
violation_error_message=_("This team is already in this template."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
@@ -917,8 +885,6 @@ class Invitation(BaseModel):
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -67,19 +67,10 @@ class AIService:
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
|
||||
|
||||
try:
|
||||
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
|
||||
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
|
||||
|
||||
json_response = json.loads(sanitized_content)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
try:
|
||||
json_response = json.loads(content)
|
||||
except json.JSONDecodeError as err:
|
||||
raise RuntimeError("AI response is not valid JSON", content) from err
|
||||
json_response = json.loads(sanitized_content)
|
||||
|
||||
if "answer" not in json_response:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Collaboration services."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class CollaborationService:
|
||||
"""Service class for Collaboration related operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Ensure that the collaboration configuration is set properly."""
|
||||
if settings.COLLABORATION_API_URL is None:
|
||||
raise ImproperlyConfigured("Collaboration configuration not set")
|
||||
|
||||
def reset_connections(self, room, user_id=None):
|
||||
"""
|
||||
Reset connections of a room in the collaboration server.
|
||||
Resetting a connection means that the user will be disconnected and will
|
||||
have to reconnect to the collaboration server, with updated rights.
|
||||
"""
|
||||
endpoint = "reset-connections"
|
||||
|
||||
# room is necessary as a parameter, it is easier to stick to the
|
||||
# same pod thanks to a parameter
|
||||
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
|
||||
|
||||
# Note: Collaboration microservice accepts only raw token, which is not recommended
|
||||
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
|
||||
if user_id:
|
||||
headers["X-User-Id"] = user_id
|
||||
|
||||
try:
|
||||
response = requests.post(endpoint_url, headers=headers, timeout=10)
|
||||
except requests.RequestException as e:
|
||||
raise requests.HTTPError("Failed to notify WebSocket server.") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise requests.HTTPError(
|
||||
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||
f"Response: {response.text}"
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Converter services."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
"""Base exception for conversion-related errors."""
|
||||
|
||||
|
||||
class ValidationError(ConversionError):
|
||||
"""Raised when the input validation fails."""
|
||||
|
||||
|
||||
class ServiceUnavailableError(ConversionError):
|
||||
"""Raised when the conversion service is unavailable."""
|
||||
|
||||
|
||||
class InvalidResponseError(ConversionError):
|
||||
"""Raised when the conversion service returns an invalid response."""
|
||||
|
||||
|
||||
class MissingContentError(ConversionError):
|
||||
"""Raised when the response is missing required content."""
|
||||
|
||||
|
||||
class YdocConverter:
|
||||
"""Service class for conversion-related operations."""
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
"""Build microservice authentication header."""
|
||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||
return settings.Y_PROVIDER_API_KEY
|
||||
|
||||
def convert_markdown(self, text):
|
||||
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||
|
||||
if not text:
|
||||
raise ValidationError("Input text cannot be empty")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||
json={
|
||||
"content": text,
|
||||
},
|
||||
headers={
|
||||
"Authorization": self.auth_header,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
response.raise_for_status()
|
||||
conversion_response = response.json()
|
||||
|
||||
except requests.RequestException as err:
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to conversion service",
|
||||
) from err
|
||||
|
||||
except ValueError as err:
|
||||
raise InvalidResponseError(
|
||||
"Could not parse conversion service response"
|
||||
) from err
|
||||
|
||||
try:
|
||||
document_content = conversion_response[
|
||||
settings.CONVERSION_API_CONTENT_FIELD
|
||||
]
|
||||
except KeyError as err:
|
||||
raise MissingContentError(
|
||||
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
|
||||
) from err
|
||||
|
||||
return document_content
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -130,12 +128,11 @@ def test_authentication_getter_existing_user_with_email(
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_sub(
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change
|
||||
and the user was identified by its "sub".
|
||||
It should update the email or name fields on the user when they change.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
@@ -165,48 +162,6 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_email(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the name fields on the user when they change
|
||||
and the user was identified by its "email" as fallback.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
@@ -258,6 +213,29 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info doesn't contain a sub."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"test": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
@@ -363,7 +341,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not match the sub but matches the email and is disabled,
|
||||
If an existing user does not matches the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
@@ -387,102 +365,3 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
# Essential claims
|
||||
|
||||
|
||||
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
|
||||
"""The sub claim should be mandatory by default."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"test": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
KeyError,
|
||||
match="sub",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"essential_claims, missing_claims",
|
||||
[
|
||||
(["email", "sub"], ["email"]),
|
||||
(["Email", "sub"], ["Email"]), # Case sensitivity
|
||||
],
|
||||
)
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_authentication_verify_claims_essential_missing(
|
||||
mock_logger,
|
||||
essential_claims,
|
||||
missing_claims,
|
||||
django_assert_num_queries,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Claims verification failed",
|
||||
),
|
||||
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
|
||||
|
||||
|
||||
@override_settings(
|
||||
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
|
||||
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
|
||||
)
|
||||
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
|
||||
"""Ensure user is authenticated when all essential claims are present."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"email": "john.doe@example.com",
|
||||
"last_name": "Doe",
|
||||
"sub": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(6):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert models.User.objects.filter(id=user.id).exists()
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.full_name == "Doe"
|
||||
assert user.short_name is None
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
@@ -11,9 +11,6 @@ from rest_framework.test import APIClient
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||
mock_reset_connections,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -319,11 +316,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_administrator_except_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is a direct administrator in a document should be allowed to update a user
|
||||
access for this document, as long as they don't try to set the role to owner.
|
||||
@@ -358,21 +351,18 @@ def test_api_document_accesses_update_administrator_except_owner(
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
if new_data["role"] == old_values["role"]:
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -430,11 +420,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_administrator_to_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a document, should not be allowed to update
|
||||
the user access of another user to grant document ownership.
|
||||
@@ -471,23 +457,16 @@ def test_api_document_accesses_update_administrator_to_owner(
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -495,11 +474,7 @@ def test_api_document_accesses_update_administrator_to_owner(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an owner in a document should be allowed to update
|
||||
a user access for this document whatever the role.
|
||||
@@ -532,24 +507,18 @@ def test_api_document_accesses_update_owner(
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -561,11 +530,7 @@ def test_api_document_accesses_update_owner(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner_self(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
|
||||
"""
|
||||
A user who is owner of a document should be allowed to update
|
||||
their own user access provided there are other owners in the document.
|
||||
@@ -603,23 +568,21 @@ def test_api_document_accesses_update_owner_self(
|
||||
# Add another owner and it should now work
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
user_id = str(access.user_id) if via == USER else None
|
||||
with mock_reset_connections(document.id, user_id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={
|
||||
**old_values,
|
||||
"role": new_role,
|
||||
"user_id": old_values.get("user", {}).get("id")
|
||||
if old_values.get("user") is not None
|
||||
else None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={
|
||||
**old_values,
|
||||
"role": new_role,
|
||||
"user_id": old_values.get("user", {}).get("id")
|
||||
if old_values.get("user") is not None
|
||||
else None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
@@ -693,12 +656,10 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_administrators_except_owners(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a document should be allowed to delete access
|
||||
Users who are administrators in a document should be allowed to delete an access
|
||||
from the document provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -724,13 +685,12 @@ def test_api_document_accesses_delete_administrators_except_owners(
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -769,11 +729,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_owners(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
def test_api_document_accesses_delete_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users should be able to delete the document access of another user
|
||||
for a document of which they are owner.
|
||||
@@ -797,13 +753,12 @@ def test_api_document_accesses_delete_owners(
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
|
||||
@@ -171,11 +171,10 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
assert (
|
||||
f"{user.full_name} ({user.email}) invited you with the role "{role}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@@ -229,9 +228,8 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
assert (
|
||||
f"{user.full_name} ({user.email}) invited you with the role "{role}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
@@ -7,7 +7,6 @@ from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -340,7 +339,6 @@ def test_api_document_invitations_create_authenticated_outsider():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(EMAIL_BRAND_NAME="My brand name", EMAIL_LOGO_IMG="my-img.jpg")
|
||||
@pytest.mark.parametrize(
|
||||
"inviting,invited,response_code",
|
||||
(
|
||||
@@ -404,13 +402,10 @@ def test_api_document_invitations_create_privileged_members(
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
assert (
|
||||
f"{user.full_name} ({user.email}) invited you with the role "{invited}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
assert "My brand name" in email_content
|
||||
assert "my-img.jpg" in email_content
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
else:
|
||||
assert models.Invitation.objects.exists() is False
|
||||
|
||||
@@ -457,7 +452,10 @@ def test_api_document_invitations_create_email_from_content_language():
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} a partagé un document avec vous!" in email_content
|
||||
assert (
|
||||
f"{user.full_name} a partagé un document avec vous: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_from_content_language_not_supported():
|
||||
@@ -496,7 +494,10 @@ def test_api_document_invitations_create_email_from_content_language_not_support
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
assert (
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_full_name_empty():
|
||||
@@ -534,10 +535,10 @@ def test_api_document_invitations_create_email_full_name_empty():
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you!" in email_content
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert (
|
||||
f"{user.email.capitalize()} invited you with the role "reader" on the "
|
||||
f"following document: {document.title}" in email_content
|
||||
f'{user.email} invited you with the role "reader" on the '
|
||||
f"following document : {document.title}" in email_content
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
|
||||
assert response.status_code == 404
|
||||
|
||||
# Create a new version should not make it available to the user because
|
||||
# only the current version is available to the user, but it is excluded
|
||||
# only the current version is available to the user but it is excluded
|
||||
# from the list
|
||||
document.content = "new content 1"
|
||||
document.save()
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Authenticated who are not related to a document should be able to request AI transform
|
||||
Autenticated who are not related to a document should be able to request AI transform
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -154,7 +154,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Authenticated who are not related to a document should be able to request AI translate
|
||||
Autenticated who are not related to a document should be able to request AI translate
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
|
||||
)
|
||||
def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
"""
|
||||
Authenticated who are not related to a document should be able to upload a file
|
||||
Autenticated who are not related to a document should be able to upload a file
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -225,7 +225,7 @@ def test_api_documents_attachment_upload_invalid(client):
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
"""The uploaded file should not exceed the maximum size in settings."""
|
||||
"""The uploaded file should not exceeed the maximum size in settings."""
|
||||
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Document, Invitation, User
|
||||
from core.services.converter_services import ConversionError, YdocConverter
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_convert_markdown():
|
||||
"""Mock YdocConverter.convert_markdown to return a converted content."""
|
||||
with patch.object(
|
||||
YdocConverter,
|
||||
"convert_markdown",
|
||||
return_value="Converted document content",
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def test_api_documents_create_for_owner_missing_token():
|
||||
"""Requests with no token should not be allowed to create documents for owner."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/", data, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_invalid_token():
|
||||
"""Requests with an invalid token should not be allowed to create documents for owner."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
"language": "fr",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer InvalidToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
def test_api_documents_create_for_owner_authenticated_forbidden():
|
||||
"""
|
||||
Authenticated users should not be allowed to call create documents on behalf of other users.
|
||||
This API endpoint is reserved for server-to-server calls.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_missing_sub():
|
||||
"""Requests with no sub should not be allowed to create documents for owner."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert not Document.objects.exists()
|
||||
|
||||
assert response.json() == {"sub": ["This field is required."]}
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_missing_email():
|
||||
"""Requests with no email should not be allowed to create documents for owner."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert not Document.objects.exists()
|
||||
|
||||
assert response.json() == {"email": ["This field is required."]}
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_invalid_sub():
|
||||
"""Requests with an invalid sub should not be allowed to create documents for owner."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123!!",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert not Document.objects.exists()
|
||||
|
||||
assert response.json() == {
|
||||
"sub": [
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
|
||||
"""It should be possible to create a document on behalf of a pre-existing user."""
|
||||
user = factories.UserFactory(language="en-us")
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": str(user.sub),
|
||||
"email": "irrelevant@example.com", # Should be ignored since the user already exists
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
|
||||
assert document.title == "My Document"
|
||||
assert document.content == "Converted document content"
|
||||
assert document.creator == user
|
||||
assert document.accesses.filter(user=user, role="owner").exists()
|
||||
|
||||
assert Invitation.objects.exists() is False
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [user.email]
|
||||
assert email.subject == "A new document was created on your behalf!"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "A new document was created on your behalf!" in email_content
|
||||
assert (
|
||||
"You have been granted ownership of a new document: My Document"
|
||||
) in email_content
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
|
||||
"""
|
||||
It should be possible to create a document on behalf of new users by
|
||||
passing only their email address.
|
||||
"""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com", # Should be used to create a new user
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
|
||||
assert document.title == "My Document"
|
||||
assert document.content == "Converted document content"
|
||||
assert document.creator is None
|
||||
assert document.accesses.exists() is False
|
||||
|
||||
invitation = Invitation.objects.get()
|
||||
assert invitation.email == "john.doe@example.com"
|
||||
assert invitation.role == "owner"
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["john.doe@example.com"]
|
||||
assert email.subject == "A new document was created on your behalf!"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "A new document was created on your behalf!" in email_content
|
||||
assert (
|
||||
"You have been granted ownership of a new document: My Document"
|
||||
) in email_content
|
||||
|
||||
# The creator field on the document should be set when the user is created
|
||||
user = User.objects.create(email="john.doe@example.com", password="!")
|
||||
document.refresh_from_db()
|
||||
assert document.creator == user
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
|
||||
"""
|
||||
Test creating a document with a specific language.
|
||||
Useful if the remote server knows the user's language.
|
||||
"""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
"language": "fr-fr",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["john.doe@example.com"]
|
||||
assert email.subject == "Un nouveau document a été créé pour vous !"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Un nouveau document a été créé pour vous !" in email_content
|
||||
assert (
|
||||
"Vous avez été déclaré propriétaire d'un nouveau document : My Document"
|
||||
) in email_content
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
mock_convert_markdown,
|
||||
):
|
||||
"""It should be possible to customize the subject and message of the invitation email."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
"message": "mon message spécial",
|
||||
"subject": "mon sujet spécial !",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["john.doe@example.com"]
|
||||
assert email.subject == "Mon sujet spécial !"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Mon sujet spécial !" in email_content
|
||||
assert "Mon message spécial" in email_content
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_converter_exception(
|
||||
mock_convert_markdown,
|
||||
):
|
||||
"""It should be possible to customize the subject and message of the invitation email."""
|
||||
|
||||
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
"message": "mon message spécial",
|
||||
"subject": "mon sujet spécial !",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "could not convert content"}
|
||||
@@ -6,9 +6,6 @@ from rest_framework.test import APIClient
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||
mock_reset_connections,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -119,10 +116,7 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
via,
|
||||
role,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
A user who is administrator or owner of a document should be allowed to update
|
||||
@@ -145,16 +139,14 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
assert value == new_document_values[key]
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
assert value == new_document_values[key]
|
||||
|
||||
@@ -26,13 +26,11 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
@@ -90,11 +88,9 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"media_auth": True,
|
||||
"link_configuration": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
|
||||
@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_public():
|
||||
def test_api_documents_retrieve_auth_anonymous_public():
|
||||
"""Anonymous users should be able to retrieve attachments linked to a public document"""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_api_documents_media_auth_anonymous_public():
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -65,7 +65,7 @@ def test_api_documents_media_auth_anonymous_public():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
|
||||
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve attachments linked to a document
|
||||
with link reach set to authenticated or restricted.
|
||||
@@ -76,7 +76,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -84,7 +84,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
|
||||
"""
|
||||
Authenticated users who are not related to a document should be able to retrieve
|
||||
attachments related to a document with public or authenticated link reach.
|
||||
@@ -107,7 +107,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -135,7 +135,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_media_auth_authenticated_restricted():
|
||||
def test_api_documents_retrieve_auth_authenticated_restricted():
|
||||
"""
|
||||
Authenticated users who are not related to a document should not be allowed to
|
||||
retrieve attachments linked to a document that is restricted.
|
||||
@@ -150,7 +150,7 @@ def test_api_documents_media_auth_authenticated_restricted():
|
||||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -158,9 +158,9 @@ def test_api_documents_media_auth_authenticated_restricted():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
|
||||
"""
|
||||
Users who have specific access to a document, whatever the role, should be able to
|
||||
Users who have a specific access to a document, whatever the role, should be able to
|
||||
retrieve related attachments.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -186,7 +186,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -647,7 +647,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a template should be allowed to delete access
|
||||
Users who are administrators in a template should be allowed to delete an access
|
||||
from the template provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -16,7 +16,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
COLLABORATION_SERVER_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
@@ -34,7 +34,7 @@ def test_api_config(is_authenticated):
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"COLLABORATION_SERVER_URL": "http://testcollab/",
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
|
||||
@@ -33,8 +33,11 @@ def test_models_documents_id_unique():
|
||||
|
||||
|
||||
def test_models_documents_creator_required():
|
||||
"""No field should be required on the Document model."""
|
||||
models.Document.objects.create()
|
||||
"""The "creator" field should be required."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
models.Document.objects.create()
|
||||
|
||||
assert excinfo.value.message_dict["creator"] == ["This field cannot be null."]
|
||||
|
||||
|
||||
def test_models_documents_title_null():
|
||||
@@ -84,7 +87,7 @@ def test_models_documents_file_key():
|
||||
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
|
||||
"""
|
||||
Check abilities returned for a document giving insufficient roles to link holders
|
||||
i.e. anonymous users or authenticated users who have no specific role on the document.
|
||||
i.e anonymous users or authenticated users who have no specific role on the document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
@@ -95,11 +98,9 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"link_configuration": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
@@ -121,7 +122,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
"""
|
||||
Check abilities returned for a document giving reader role to link holders
|
||||
i.e. anonymous users or authenticated users who have no specific role on the document.
|
||||
i.e anonymous users or authenticated users who have no specific role on the document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
@@ -132,12 +133,10 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -158,7 +157,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
"""
|
||||
Check abilities returned for a document giving editor role to link holders
|
||||
i.e. anonymous users or authenticated users who have no specific role on the document.
|
||||
i.e anonymous users or authenticated users who have no specific role on the document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
@@ -169,12 +168,10 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -195,12 +192,10 @@ def test_models_documents_get_abilities_owner():
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -220,12 +215,10 @@ def test_models_documents_get_abilities_administrator():
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -248,12 +241,10 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -278,12 +269,10 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -309,12 +298,10 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -427,8 +414,8 @@ def test_models_documents__email_invitation__success():
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
document.email_invitation(
|
||||
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -441,15 +428,15 @@ def test_models_documents__email_invitation__success():
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"Test Sender (sender@example.com) invited you with the role "editor" "
|
||||
f"on the following document: {document.title}" in email_content
|
||||
f'Test Sender (sender@example.com) invited you with the role "editor" '
|
||||
f"on the following document : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_fr():
|
||||
"""
|
||||
The email invitation is sent successfully in French.
|
||||
The email invitation is sent successfully in french.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
@@ -459,11 +446,11 @@ def test_models_documents__email_invitation__success_fr():
|
||||
sender = factories.UserFactory(
|
||||
full_name="Test Sender2", email="sender2@example.com"
|
||||
)
|
||||
document.send_invitation_email(
|
||||
document.email_invitation(
|
||||
"fr-fr",
|
||||
"guest2@example.com",
|
||||
models.RoleChoices.OWNER,
|
||||
sender,
|
||||
"fr-fr",
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -476,8 +463,8 @@ def test_models_documents__email_invitation__success_fr():
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" "
|
||||
f"sur le document suivant: {document.title}" in email_content
|
||||
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
|
||||
f"sur le document suivant : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
@@ -495,11 +482,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory()
|
||||
document.send_invitation_email(
|
||||
document.email_invitation(
|
||||
"en",
|
||||
"guest3@example.com",
|
||||
models.RoleChoices.ADMIN,
|
||||
sender,
|
||||
"en",
|
||||
)
|
||||
|
||||
# No email has been sent
|
||||
@@ -511,9 +498,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
|
||||
(
|
||||
_,
|
||||
emails,
|
||||
email,
|
||||
exception,
|
||||
) = mock_logger.call_args.args
|
||||
|
||||
assert emails == ["guest3@example.com"]
|
||||
assert email == "guest3@example.com"
|
||||
assert isinstance(exception, smtplib.SMTPException)
|
||||
|
||||
@@ -144,7 +144,7 @@ def test_models_invitationd_new_user_filter_expired_invitations():
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
|
||||
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
|
||||
django_assert_num_queries, num_invitations, num_queries
|
||||
):
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_models_users_id_unique():
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_existing():
|
||||
"""The 'email_user' method should send mail to the user's email address."""
|
||||
"""The "email_user' method should send mail to the user's email address."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||
@@ -37,7 +37,7 @@ def test_models_users_send_mail_main_existing():
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_missing():
|
||||
"""The 'email_user' method should fail if the user has no email address."""
|
||||
"""The "email_user' method should fail if the user has no email address."""
|
||||
user = factories.UserFactory(email=None)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test AI API endpoints in the impress core app.
|
||||
Test ai API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -102,24 +102,3 @@ def test_api_ai__success_sanitize(mock_create):
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut\n \tle \nmonde"}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success_when_sanitize_fails(mock_create):
|
||||
"""The AI request should work as expected even with badly formatted response."""
|
||||
|
||||
# pylint: disable=C0303
|
||||
answer = """{
|
||||
"answer" :
|
||||
"Salut le monde"
|
||||
}"""
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut le monde"}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"""
|
||||
This module contains tests for the CollaborationService class in the
|
||||
core.services.collaboration_services module.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reset_connections(settings):
|
||||
"""
|
||||
Creates a context manager to mock the reset-connections endpoint for collaboration services.
|
||||
Args:
|
||||
settings: A settings object that contains the configuration for the collaboration API.
|
||||
Returns:
|
||||
A context manager function that mocks the reset-connections endpoint.
|
||||
The context manager function takes the following parameters:
|
||||
document_id (str): The ID of the document for which connections are being reset.
|
||||
user_id (str, optional): The ID of the user making the request. Defaults to None.
|
||||
Usage:
|
||||
with mock_reset_connections(settings)(document_id, user_id) as mock:
|
||||
# Your test code here
|
||||
The context manager performs the following actions:
|
||||
- Mocks the reset-connections endpoint using responses.RequestsMock.
|
||||
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
|
||||
- Verifies that the reset-connections endpoint is called exactly once.
|
||||
- Checks that the request URL and headers are correct.
|
||||
- If user_id is provided, checks that the X-User-Id header is correct.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def _mock_reset_connections(document_id, user_id=None):
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Mock the reset-connections endpoint
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
|
||||
)
|
||||
rsps.add(
|
||||
responses.POST,
|
||||
endpoint_url,
|
||||
json={},
|
||||
status=200,
|
||||
)
|
||||
yield
|
||||
|
||||
assert (
|
||||
len(rsps.calls) == 1
|
||||
), "Expected one call to reset-connections endpoint"
|
||||
request = rsps.calls[0].request
|
||||
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
|
||||
assert (
|
||||
request.headers.get("Authorization")
|
||||
== settings.COLLABORATION_SERVER_SECRET
|
||||
), "Incorrect Authorization header"
|
||||
|
||||
if user_id:
|
||||
assert (
|
||||
request.headers.get("X-User-Id") == user_id
|
||||
), "Incorrect X-User-Id header"
|
||||
|
||||
return _mock_reset_connections
|
||||
|
||||
|
||||
def test_init_without_api_url(settings):
|
||||
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
|
||||
settings.COLLABORATION_API_URL = None
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
CollaborationService()
|
||||
|
||||
|
||||
def test_init_with_api_url(settings):
|
||||
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
service = CollaborationService()
|
||||
assert isinstance(service, CollaborationService)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_reset_connections_with_user_id(settings):
|
||||
"""Test reset_connections with a provided user_id."""
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
service = CollaborationService()
|
||||
|
||||
room = "room1"
|
||||
user_id = "user123"
|
||||
endpoint_url = "http://example.com/reset-connections/?room=" + room
|
||||
|
||||
responses.add(responses.POST, endpoint_url, json={}, status=200)
|
||||
|
||||
service.reset_connections(room, user_id)
|
||||
|
||||
assert len(responses.calls) == 1
|
||||
request = responses.calls[0].request
|
||||
|
||||
assert request.url == endpoint_url
|
||||
assert request.headers.get("Authorization") == "secret-token"
|
||||
assert request.headers.get("X-User-Id") == "user123"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_reset_connections_without_user_id(settings):
|
||||
"""Test reset_connections without a user_id."""
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
service = CollaborationService()
|
||||
|
||||
room = "room1"
|
||||
user_id = None
|
||||
endpoint_url = "http://example.com/reset-connections/?room=" + room
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
endpoint_url,
|
||||
json={},
|
||||
status=200,
|
||||
)
|
||||
|
||||
service.reset_connections(room, user_id)
|
||||
|
||||
assert len(responses.calls) == 1
|
||||
request = responses.calls[0].request
|
||||
|
||||
assert request.url == endpoint_url
|
||||
assert request.headers.get("Authorization") == "secret-token"
|
||||
assert request.headers.get("X-User-Id") is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_reset_connections_non_200_response(settings):
|
||||
"""Test that an HTTPError is raised when the response status is not 200."""
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
service = CollaborationService()
|
||||
|
||||
room = "room1"
|
||||
user_id = "user123"
|
||||
endpoint_url = "http://example.com/reset-connections/?room=" + room
|
||||
response_body = {"error": "Internal Server Error"}
|
||||
|
||||
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
|
||||
|
||||
expected_exception_message = re.escape(
|
||||
"Failed to notify WebSocket server. Status code: 500, Response: "
|
||||
) + re.escape(json.dumps(response_body))
|
||||
|
||||
with pytest.raises(requests.HTTPError, match=expected_exception_message):
|
||||
service.reset_connections(room, user_id)
|
||||
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_reset_connections_request_exception(settings):
|
||||
"""Test that an HTTPError is raised when a RequestException occurs."""
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
service = CollaborationService()
|
||||
|
||||
room = "room1"
|
||||
user_id = "user123"
|
||||
endpoint_url = "http://example.com/reset-connections?room=" + room
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
endpoint_url,
|
||||
body=requests.exceptions.ConnectionError("Network error"),
|
||||
)
|
||||
|
||||
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
|
||||
service.reset_connections(room, user_id)
|
||||
|
||||
assert len(responses.calls) == 1
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Test converter services."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from core.services.converter_services import (
|
||||
InvalidResponseError,
|
||||
MissingContentError,
|
||||
ServiceUnavailableError,
|
||||
ValidationError,
|
||||
YdocConverter,
|
||||
)
|
||||
|
||||
|
||||
def test_auth_header(settings):
|
||||
"""Test authentication header generation."""
|
||||
settings.Y_PROVIDER_API_KEY = "test-key"
|
||||
converter = YdocConverter()
|
||||
assert converter.auth_header == "test-key"
|
||||
|
||||
|
||||
def test_convert_markdown_empty_text():
|
||||
"""Should raise ValidationError when text is empty."""
|
||||
converter = YdocConverter()
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
converter.convert_markdown("")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_service_unavailable(mock_post):
|
||||
"""Should raise ServiceUnavailableError when service is unavailable."""
|
||||
converter = YdocConverter()
|
||||
|
||||
mock_post.side_effect = requests.RequestException("Connection error")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
):
|
||||
converter.convert_markdown("test text")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_http_error(mock_post):
|
||||
"""Should raise ServiceUnavailableError when HTTP error occurs."""
|
||||
converter = YdocConverter()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
):
|
||||
converter.convert_markdown("test text")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_invalid_json_response(mock_post):
|
||||
"""Should raise InvalidResponseError when response is not valid JSON."""
|
||||
converter = YdocConverter()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
InvalidResponseError,
|
||||
match="Could not parse conversion service response",
|
||||
):
|
||||
converter.convert_markdown("test text")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_missing_content_field(mock_post, settings):
|
||||
"""Should raise MissingContentError when response is missing required field."""
|
||||
|
||||
settings.CONVERSION_API_CONTENT_FIELD = "expected_field"
|
||||
|
||||
converter = YdocConverter()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"wrong_field": "content"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
MissingContentError,
|
||||
match="Response missing required field: expected_field",
|
||||
):
|
||||
converter.convert_markdown("test text")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_full_integration(mock_post, settings):
|
||||
"""Test full integration with all settings."""
|
||||
|
||||
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
|
||||
settings.Y_PROVIDER_API_KEY = "test-key"
|
||||
settings.CONVERSION_API_ENDPOINT = "conversion-endpoint"
|
||||
settings.CONVERSION_API_TIMEOUT = 5
|
||||
settings.CONVERSION_API_CONTENT_FIELD = "content"
|
||||
|
||||
converter = YdocConverter()
|
||||
|
||||
expected_content = {"converted": "content"}
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"content": expected_content}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = converter.convert_markdown("test markdown")
|
||||
|
||||
assert result == expected_content
|
||||
mock_post.assert_called_once_with(
|
||||
"http://test.com/conversion-endpoint/",
|
||||
json={"content": "test markdown"},
|
||||
headers={
|
||||
"Authorization": "test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_convert_markdown_timeout(mock_post):
|
||||
"""Should raise ServiceUnavailableError when request times out."""
|
||||
converter = YdocConverter()
|
||||
|
||||
mock_post.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
):
|
||||
converter.convert_markdown("test text")
|
||||
|
||||
|
||||
def test_convert_markdown_none_input():
|
||||
"""Should raise ValidationError when input is None."""
|
||||
converter = YdocConverter()
|
||||
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
converter.convert_markdown(None)
|
||||
@@ -15,7 +15,7 @@ class Command(BaseCommand):
|
||||
"""Define required arguments "email" and "password"."""
|
||||
parser.add_argument(
|
||||
"--email",
|
||||
help="Email for the user.",
|
||||
help=("Email for the user."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
|
||||
@@ -65,7 +65,6 @@ class Base(Configuration):
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "impress.urls"
|
||||
@@ -352,11 +351,9 @@ class Base(Configuration):
|
||||
|
||||
# Mail
|
||||
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_HOST = values.Value(None)
|
||||
EMAIL_HOST_USER = values.Value(None)
|
||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
@@ -375,14 +372,8 @@ class Base(Configuration):
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Collaboration
|
||||
COLLABORATION_API_URL = values.Value(
|
||||
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
|
||||
)
|
||||
COLLABORATION_SERVER_SECRET = values.Value(
|
||||
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
|
||||
)
|
||||
COLLABORATION_WS_URL = values.Value(
|
||||
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
|
||||
COLLABORATION_SERVER_URL = values.Value(
|
||||
None, environ_name="COLLABORATION_SERVER_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Frontend
|
||||
@@ -474,25 +465,9 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
|
||||
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
|
||||
)
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
|
||||
default="first_name",
|
||||
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
# AI service
|
||||
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
||||
@@ -508,35 +483,14 @@ class Base(Configuration):
|
||||
"day": 200,
|
||||
}
|
||||
|
||||
# Y provider microservice
|
||||
Y_PROVIDER_API_KEY = values.Value(
|
||||
environ_name="Y_PROVIDER_API_KEY",
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
Y_PROVIDER_API_BASE_URL = values.Value(
|
||||
environ_name="Y_PROVIDER_API_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Conversion endpoint
|
||||
CONVERSION_API_ENDPOINT = values.Value(
|
||||
default="convert-markdown",
|
||||
environ_name="CONVERSION_API_ENDPOINT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
CONVERSION_API_CONTENT_FIELD = values.Value(
|
||||
default="content",
|
||||
environ_name="CONVERSION_API_CONTENT_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
CONVERSION_API_TIMEOUT = values.Value(
|
||||
default=30,
|
||||
environ_name="CONVERSION_API_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
CONVERSION_API_SECURE = values.Value(
|
||||
default=False,
|
||||
environ_name="CONVERSION_API_SECURE",
|
||||
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
|
||||
default="first_name",
|
||||
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -622,9 +576,8 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
|
||||
Binary file not shown.
@@ -2,66 +2,46 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
|
||||
"PO-Revision-Date: 2024-09-25 10:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Crowdin-Project: lasuite-people\n"
|
||||
"X-Crowdin-Project-ID: 637934\n"
|
||||
"X-Crowdin-Language: de\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:32
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
msgstr "Persönliche Angaben"
|
||||
|
||||
#: core/admin.py:46
|
||||
#: core/admin.py:34
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:58
|
||||
#: core/admin.py:46
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
msgstr "Wichtige Termine"
|
||||
|
||||
#: core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: core/api/serializers.py:253
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: core/api/serializers.py:256
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: core/api/serializers.py:262
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
#: core/authentication/backends.py:56
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
@@ -69,17 +49,17 @@ msgstr ""
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
msgid "User account is disabled"
|
||||
#: core/authentication/backends.py:101
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
msgstr "Leser"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
msgstr "Bearbeiter"
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "Administrator"
|
||||
@@ -87,308 +67,283 @@ msgstr "Administrator"
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:80
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
msgstr "Eingeschränkt"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:84
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:86
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:98
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: core/models.py:99
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:105
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: core/models.py:109
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: core/models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: core/models.py:115
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
|
||||
|
||||
#: core/models.py:135
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:106
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:132
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:155
|
||||
#: core/models.py:148
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:153
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:160
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:161
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:167
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:170
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: core/models.py:175
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:180
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:183
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:195
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:196
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: core/models.py:328 core/models.py:644
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:343
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:365
|
||||
#: core/models.py:344
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: core/models.py:347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: core/models.py:593
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:597
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
#: core/models.py:537
|
||||
#, python-format
|
||||
msgid "%(username)s shared a document with you: %(document)s"
|
||||
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
|
||||
|
||||
#: core/models.py:600
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:623
|
||||
#: core/models.py:580
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
#: core/models.py:581
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
#: core/models.py:587
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:654
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:660
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:682
|
||||
#: core/models.py:608
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
#: core/models.py:609
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:615
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:695
|
||||
#: core/models.py:621
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: core/models.py:627 core/models.py:816
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:645
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
#: core/models.py:646
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
#: core/models.py:647
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:723
|
||||
#: core/models.py:649
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:651
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:657
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:658
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
#: core/models.py:797
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
#: core/models.py:798
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
#: core/models.py:804
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:810
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
#: core/models.py:833
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
#: core/models.py:850
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
#: core/models.py:851
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
#: core/models.py:868
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/html/invitation2.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
#: core/templates/mail/text/invitation2.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(username)s shared a document with you ! "
|
||||
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:197
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(username)s invited you as an %(role)s on the following document : "
|
||||
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:206
|
||||
#: core/templates/mail/html/invitation2.html:211
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
#: core/templates/mail/text/invitation2.txt:11
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/html/invitation.html:223
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
|
||||
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/html/invitation2.html:235
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
#: core/templates/mail/text/invitation2.txt:17
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Bereitgestellt von La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#: core/templates/mail/html/invitation2.html:190
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
msgid "%(username)s shared a document with you"
|
||||
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: core/templates/mail/html/invitation2.html:197
|
||||
#: core/templates/mail/text/invitation2.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s invited you as an %(role)s on the following document :"
|
||||
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:228
|
||||
#: core/templates/mail/text/invitation2.txt:15
|
||||
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
|
||||
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
|
||||
|
||||
#: impress/settings.py:177
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: impress/settings.py:178
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: impress/settings.py:176
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -29,35 +29,15 @@ msgstr ""
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: core/api/serializers.py:253
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: core/api/serializers.py:256
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: core/api/serializers.py:262
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -69,10 +49,6 @@ msgstr ""
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
@@ -89,246 +65,224 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:80
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:84
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:86
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:98
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: core/models.py:99
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:105
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:109
|
||||
#: core/models.py:106
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:111
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:135
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
#: core/models.py:132
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:138
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:155
|
||||
#: core/models.py:152
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:157
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:164
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:165
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:171
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:174
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:176
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: core/models.py:179
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:184
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:187
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:199
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:200
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: core/models.py:332 core/models.py:638
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:347
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:365
|
||||
#: core/models.py:348
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: core/models.py:351
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
#: core/models.py:530
|
||||
#, python-format
|
||||
msgid "%(sender_name)s shared a document with you: %(document)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:597
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:600
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:623
|
||||
#: core/models.py:574
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
#: core/models.py:575
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
#: core/models.py:581
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:654
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:660
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:682
|
||||
#: core/models.py:602
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
#: core/models.py:603
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:609
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:695
|
||||
#: core/models.py:615
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: core/models.py:621 core/models.py:810
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:639
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
#: core/models.py:640
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
#: core/models.py:641
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:723
|
||||
#: core/models.py:643
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:645
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:651
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:652
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
#: core/models.py:791
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
#: core/models.py:792
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
#: core/models.py:798
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:804
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
#: core/models.py:827
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
#: core/models.py:844
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
#: core/models.py:845
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
#: core/models.py:862
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -354,25 +308,36 @@ msgstr ""
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(sender_name)s shared a document with you ! "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:196
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:205
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
@@ -380,15 +345,14 @@ msgstr ""
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: impress/settings.py:177
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: impress/settings.py:178
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: impress/settings.py:176
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -29,35 +29,15 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: core/api/serializers.py:253
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: core/api/serializers.py:256
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: core/api/serializers.py:262
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -69,10 +49,6 @@ msgstr ""
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
@@ -89,246 +65,224 @@ msgstr "Administrateur"
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:80
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:84
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:86
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:98
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: core/models.py:99
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:105
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:109
|
||||
#: core/models.py:106
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:111
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:135
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
#: core/models.py:132
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:138
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:155
|
||||
#: core/models.py:152
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:157
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:164
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:165
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:171
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:174
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:176
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: core/models.py:179
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:184
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:187
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:199
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:200
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: core/models.py:332 core/models.py:638
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:347
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:365
|
||||
#: core/models.py:348
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: core/models.py:351
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
#: core/models.py:530
|
||||
#, python-format
|
||||
msgid "%(sender_name)s shared a document with you: %(document)s"
|
||||
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
|
||||
|
||||
#: core/models.py:597
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
|
||||
|
||||
#: core/models.py:600
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: core/models.py:623
|
||||
#: core/models.py:574
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
#: core/models.py:575
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
#: core/models.py:581
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:654
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:660
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:682
|
||||
#: core/models.py:602
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
#: core/models.py:603
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
#: core/models.py:609
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:695
|
||||
#: core/models.py:615
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: core/models.py:621 core/models.py:810
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:639
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
#: core/models.py:640
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
#: core/models.py:641
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:723
|
||||
#: core/models.py:643
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:645
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:651
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:652
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
#: core/models.py:791
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
#: core/models.py:792
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
#: core/models.py:798
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:804
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
#: core/models.py:827
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
#: core/models.py:844
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
#: core/models.py:845
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
#: core/models.py:862
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -354,41 +308,51 @@ msgstr ""
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(sender_name)s shared a document with you ! "
|
||||
msgstr " %(sender_name)s a partagé un document avec vous ! "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:196
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
|
||||
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:205
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " Proposé par %(brandname)s "
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Proposé par La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: impress/settings.py:177
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: impress/settings.py:178
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: impress/settings.py:176
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.10.0"
|
||||
version = "1.8.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,21 +25,21 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3==1.35.90",
|
||||
"boto3==1.35.44",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.5.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-filter==24.3",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"redis==5.1.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.4",
|
||||
"django==5.1.2",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
@@ -47,17 +47,17 @@ dependencies = [
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.58.1",
|
||||
"openai==1.55.3",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.10.1",
|
||||
"PyJWT==2.9.0",
|
||||
"pypandoc==1.14",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.19.2",
|
||||
"sentry-sdk==2.17.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -70,20 +70,20 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.3",
|
||||
"ipython==8.28.0",
|
||||
"pyfakefs==5.7.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.3",
|
||||
"pytest-cov==6.0.0",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.8.4",
|
||||
"ruff==0.7.0",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
FROM node:20-alpine AS frontend-deps-y-provider
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/yarn.lock ./yarn.lock
|
||||
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
|
||||
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
# ---- y-provider ----
|
||||
FROM frontend-deps-y-provider AS y-provider
|
||||
|
||||
WORKDIR /home/frontend/servers/y-provider
|
||||
RUN yarn build
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
@@ -10,9 +40,7 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
|
||||
COPY ./src/frontend/packages/eslint-config-impress ./packages/eslint-config-impress
|
||||
COPY ./src/frontend/apps/impress ./apps/impress
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps AS impress
|
||||
|
||||
@@ -67,9 +67,7 @@ export const addNewMember = async (
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
@@ -84,9 +82,13 @@ export const addNewMember = async (
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`User ${users[index].email} added to the document.`),
|
||||
).toBeVisible();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
@@ -104,10 +106,10 @@ export const goToGridDoc = async (
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(docsGrid.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
|
||||
const rows = docsGrid.getByRole('row');
|
||||
|
||||
expect(await rows.count()).toEqual(20);
|
||||
const row = title
|
||||
? rows.filter({
|
||||
hasText: title,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createDoc, verifyDocName } from './common';
|
||||
|
||||
const config = {
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
|
||||
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
@@ -93,7 +93,6 @@ test.describe('Config', () => {
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Anything');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByText('Upload image').click();
|
||||
@@ -118,7 +117,7 @@ test.describe('Config', () => {
|
||||
browserName,
|
||||
}) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
|
||||
return webSocket.url().includes('ws://localhost:4444/');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
@@ -133,7 +132,7 @@ test.describe('Config', () => {
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/');
|
||||
});
|
||||
|
||||
test('it checks that Crisp is trying to init from config endpoint', async ({
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -24,55 +18,11 @@ test.describe('Doc Create', () => {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
await expect(docsGrid.getByText(docTitle)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Create: Not loggued', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('it creates a doc server way', async ({
|
||||
page,
|
||||
browserName,
|
||||
request,
|
||||
}) => {
|
||||
const markdown = `This is a normal text\n\n# And this is a large heading`;
|
||||
const [title] = randomName('My server way doc create', browserName, 1);
|
||||
const data = {
|
||||
title,
|
||||
content: markdown,
|
||||
sub: `user@${browserName}.e2e`,
|
||||
email: `user@${browserName}.e2e`,
|
||||
};
|
||||
|
||||
const newDoc = await request.post(
|
||||
`http://localhost:8071/api/v1.0/documents/create-for-owner/`,
|
||||
{
|
||||
data,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-e2e',
|
||||
format: 'json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(newDoc.ok()).toBeTruthy();
|
||||
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
await goToGridDoc(page, { title });
|
||||
|
||||
await verifyDocName(page, title);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await expect(editor.getByText('This is a normal text')).toBeVisible();
|
||||
await expect(
|
||||
editor.locator('h1').getByText('And this is a large heading'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,67 +86,26 @@ test.describe('Doc Editor', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* We check:
|
||||
* - connection to the collaborative server
|
||||
* - signal of the backend to the collaborative server (connection should close)
|
||||
* - reconnection to the collaborative server
|
||||
*/
|
||||
test('checks the connection with collaborative server', async ({
|
||||
test('checks the Doc is connected to the provider server', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:4444/');
|
||||
});
|
||||
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
'ws://localhost:8083/collaboration/ws/?room=',
|
||||
);
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/');
|
||||
|
||||
// Is connected
|
||||
let framesentPromise = webSocket.waitForEvent('framesent');
|
||||
const framesentPromise = webSocket.waitForEvent('framesent');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
let framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
// When the visibility is changed, the ws should close the connection (backend signal)
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Checkt the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
webSocket = await webSocketPromise;
|
||||
framesentPromise = webSocket.waitForEvent('framesent');
|
||||
framesent = await framesentPromise;
|
||||
const framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -204,15 +163,6 @@ test.describe('Doc Editor', () => {
|
||||
await verifyDocName(page, firstDoc);
|
||||
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
|
||||
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it saves the doc when we change pages', async ({
|
||||
@@ -281,14 +231,13 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it adds an image to the doc editor', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-image', browserName, 1);
|
||||
test('it adds an image to the doc editor', async ({ page }) => {
|
||||
await goToGridDoc(page);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
type SmallDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
test.describe('Document favorite', () => {
|
||||
test('it check the favorite workflow', async ({ page, browserName }) => {
|
||||
const id = Math.random().toString(7);
|
||||
await page.goto('/');
|
||||
|
||||
// Create document
|
||||
const createdDoc = await createDoc(page, `Doc ${id}`, browserName, 1);
|
||||
await verifyDocName(page, createdDoc[0]);
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.goto('/');
|
||||
|
||||
// Get all documents
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await docsGrid.getByRole('heading', { name: 'All docs' }).click();
|
||||
await expect(docsGrid.getByText(`Doc ${id}`)).toBeVisible();
|
||||
const doc = docs.find((doc) => doc.title === createdDoc[0]) as SmallDoc;
|
||||
|
||||
// Check document
|
||||
expect(doc).not.toBeUndefined();
|
||||
expect(doc?.title).toBe(createdDoc[0]);
|
||||
|
||||
// Open document actions
|
||||
const button = docsGrid.getByTestId(`docs-grid-actions-button-${doc.id}`);
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
|
||||
// Pin document
|
||||
const pinButton = page.getByTestId(`docs-grid-actions-pin-${docs[0].id}`);
|
||||
await expect(pinButton).toBeVisible();
|
||||
await pinButton.click();
|
||||
|
||||
// Check response
|
||||
const responsePin = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`documents/${doc.id}/favorite/`) &&
|
||||
response.status() === 201,
|
||||
);
|
||||
expect(responsePin.ok()).toBeTruthy();
|
||||
|
||||
// Check left panel favorites
|
||||
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
|
||||
await expect(leftPanelFavorites).toBeVisible();
|
||||
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeVisible();
|
||||
|
||||
//
|
||||
await button.click();
|
||||
const unpinButton = page.getByTestId(
|
||||
`docs-grid-actions-unpin-${docs[0].id}`,
|
||||
);
|
||||
await expect(unpinButton).toBeVisible();
|
||||
await unpinButton.click();
|
||||
|
||||
// Check left panel favorites
|
||||
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ test.describe('Documents Grid mobile', () => {
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
|
||||
const rows = docsGrid.getByRole('row');
|
||||
const row = rows.filter({
|
||||
@@ -96,7 +96,7 @@ test.describe('Document grid item options', () => {
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.url().includes('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
@@ -113,7 +113,7 @@ test.describe('Document grid item options', () => {
|
||||
await removeButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -124,7 +124,7 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
const refetchResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.url().includes('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
@@ -186,15 +186,15 @@ test.describe('Document grid item options', () => {
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
// All Docs
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.url().includes('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
const allCount = result.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
|
||||
const allDocs = page.getByLabel('All docs');
|
||||
const myDocs = page.getByLabel('My docs');
|
||||
@@ -227,15 +227,15 @@ test.describe('Documents filters', () => {
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('my_docs');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
|
||||
const responseMyDocs = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
|
||||
response.url().includes('documents/?page=1&is_creator_me=true') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const resultMyDocs = await responseMyDocs.json();
|
||||
const countMyDocs = resultMyDocs.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
expect(countMyDocs).toBeLessThanOrEqual(allCount);
|
||||
|
||||
// Shared with me
|
||||
@@ -243,7 +243,7 @@ test.describe('Documents filters', () => {
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('shared_with_me');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
|
||||
const responseSharedWithMe = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('documents/?page=1&is_creator_me=false') &&
|
||||
@@ -251,7 +251,7 @@ test.describe('Documents filters', () => {
|
||||
);
|
||||
const resultSharedWithMe = await responseSharedWithMe.json();
|
||||
const countSharedWithMe = resultSharedWithMe.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
|
||||
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
|
||||
});
|
||||
@@ -264,17 +264,17 @@ test.describe('Documents Grid', () => {
|
||||
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
let docs: SmallDoc[] = [];
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
|
||||
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.url().includes('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
|
||||
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
|
||||
|
||||
const thead = page.getByTestId('docs-grid-header');
|
||||
@@ -294,13 +294,13 @@ test.describe('Documents Grid', () => {
|
||||
let docs: SmallDoc[] = [];
|
||||
const responsePromisePage1 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/documents/?page=1`) &&
|
||||
response.url().includes(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromisePage2 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/documents/?page=2`) &&
|
||||
response.url().includes(`/documents/?page=2`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
@@ -317,6 +317,7 @@ test.describe('Documents Grid', () => {
|
||||
);
|
||||
|
||||
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
|
||||
|
||||
const responsePage2 = await responsePromisePage2;
|
||||
result = await responsePage2.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
|
||||
@@ -94,13 +94,7 @@ test.describe('Doc Header', () => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -161,40 +155,34 @@ test.describe('Doc Header', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
await expect(shareModal).toBeVisible();
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).not.toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeVisible();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByText('test@accesses.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
@@ -241,24 +229,36 @@ test.describe('Doc Header', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -303,24 +303,36 @@ test.describe('Doc Header', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -394,7 +406,9 @@ test.describe('Doc Header', () => {
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
|
||||
expect(clipboardContent.trim()).toBe(
|
||||
`<h1 data-level="1">Hello World</h1><p></p>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,82 +16,163 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1 and verify tag
|
||||
// Select user 1
|
||||
await inputSearch.fill('user');
|
||||
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()).results as {
|
||||
email: string;
|
||||
full_name: string;
|
||||
}[];
|
||||
|
||||
const list = page.getByTestId('doc-share-add-member-list');
|
||||
await expect(list).toBeHidden();
|
||||
const quickSearchContent = page.getByTestId('doc-share-quick-search');
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[0].email}`)
|
||||
.click();
|
||||
await page.getByRole('option', { name: users[0].email }).click();
|
||||
|
||||
await expect(list).toBeVisible();
|
||||
await expect(
|
||||
list.getByTestId(`doc-share-add-member-${users[0].email}`),
|
||||
).toBeVisible();
|
||||
await expect(list.getByText(`${users[0].full_name}`)).toBeVisible();
|
||||
|
||||
// Select user 2 and verify tag
|
||||
// Select user 2
|
||||
await inputSearch.fill('user');
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[1].email}`)
|
||||
.click();
|
||||
await page.getByRole('option', { name: users[1].email }).click();
|
||||
|
||||
await expect(
|
||||
list.getByTestId(`doc-share-add-member-${users[1].email}`),
|
||||
).toBeVisible();
|
||||
await expect(list.getByText(`${users[1].full_name}`)).toBeVisible();
|
||||
|
||||
// Select email and verify tag
|
||||
// Select email
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await quickSearchContent.getByText(email).click();
|
||||
await expect(list.getByText(email)).toBeVisible();
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Check user 1 tag
|
||||
await expect(
|
||||
page.getByText(`${users[0].email}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel(`Remove ${users[0].email}`)).toBeVisible();
|
||||
|
||||
// Check user 2 tag
|
||||
await expect(
|
||||
page.getByText(`${users[1].email}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel(`Remove ${users[1].email}`)).toBeVisible();
|
||||
|
||||
// Check invitation tag
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
|
||||
// Validate
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation added
|
||||
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText('Pending invitations'),
|
||||
page.getByRole('option', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it sends a new invitation and adds a new user', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/users/?q=user') && response.status() === 200,
|
||||
);
|
||||
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill('user');
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const [user] = (await responseSearchUser.json()).results as {
|
||||
email: string;
|
||||
}[];
|
||||
await page.getByRole('option', { name: user.email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
const responsePromiseAddUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/accesses/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
responseCreateInvitation.request().headers()['content-language'],
|
||||
).toBe('en-us');
|
||||
|
||||
// Check user added
|
||||
await expect(page.getByText('Share with 3 users')).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[0].full_name).first(),
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
).toBeVisible();
|
||||
const responseAddUser = await responsePromiseAddUser;
|
||||
expect(responseAddUser.ok()).toBeTruthy();
|
||||
expect(responseAddUser.request().headers()['content-language']).toBe(
|
||||
'en-us',
|
||||
);
|
||||
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[0].email).first(),
|
||||
listInvitation.locator('li').getByText('Invited'),
|
||||
).toBeVisible();
|
||||
|
||||
const listMember = page.getByLabel('List members card');
|
||||
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it try to add twice the same user', async ({ page, browserName }) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/users/?q=user') && response.status() === 200,
|
||||
);
|
||||
|
||||
await createDoc(page, 'user-twice', browserName, 1);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
await inputSearch.fill('user');
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const [user] = (await responseSearchUser.json()).results as {
|
||||
email: string;
|
||||
}[];
|
||||
await page.getByRole('option', { name: user.email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseAddMember = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/accesses/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[1].email).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[1].full_name).first(),
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
).toBeVisible();
|
||||
const responseAddMember = await responsePromiseAddMember;
|
||||
expect(responseAddMember.ok()).toBeTruthy();
|
||||
|
||||
await inputSearch.fill('user');
|
||||
await expect(page.getByText('Loading...')).toBeHidden();
|
||||
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
|
||||
});
|
||||
|
||||
test('it try to add twice the same invitation', async ({
|
||||
@@ -102,43 +183,40 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
const [email] = randomName('test@test.fr', browserName, 1);
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
// Choose a role
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 400,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await expect(
|
||||
page.getByText(`"${email}" is already invited to the document.`),
|
||||
).toBeVisible();
|
||||
@@ -155,32 +233,31 @@ test.describe('Document create member', () => {
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('EN').click();
|
||||
await header.getByRole('option', { name: 'translate Français' }).click();
|
||||
await header.getByRole('option', { name: 'FR' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(
|
||||
/Trouver un membre à ajouter au document/,
|
||||
);
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrateur' }).click();
|
||||
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await page.getByRole('button', { name: 'Valider' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
|
||||
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
@@ -193,46 +270,41 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
|
||||
const listInvitation = page.getByTestId('doc-share-quick-search');
|
||||
const userInvitation = listInvitation.getByTestId(
|
||||
`doc-share-invitation-row-${email}`,
|
||||
);
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
const li = listInvitation.locator('li').filter({
|
||||
hasText: email,
|
||||
});
|
||||
await moreActions.click();
|
||||
await expect(li.getByText(email)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
await li.getByRole('combobox', { name: /Role/ }).click();
|
||||
await li.getByRole('option', { name: 'Reader' }).click();
|
||||
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
|
||||
await li.getByText('delete').click();
|
||||
await expect(
|
||||
page.getByText(`The invitation has been removed.`),
|
||||
).toBeVisible();
|
||||
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { waitForElementCount } from '../helpers';
|
||||
|
||||
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -13,11 +15,10 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
|
||||
const pageId = url.searchParams.get('page');
|
||||
const accesses = {
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -46,23 +47,28 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const prefix = 'doc-share-member-row';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-members');
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
await expect(list.locator('li')).toHaveCount(20);
|
||||
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
|
||||
const loadMoreButton = page
|
||||
.getByLabel('List members card')
|
||||
.getByRole('button', { name: 'arrow_downward Load more' });
|
||||
await loadMoreButton.scrollIntoViewIfNeeded();
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
|
||||
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
|
||||
|
||||
await expect(loadMore).toBeHidden();
|
||||
expect(await list.locator('li').count()).toBeGreaterThan(20);
|
||||
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-1-16`),
|
||||
).toBeVisible();
|
||||
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-2-15`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks a big list of invitations', async ({ page }) => {
|
||||
@@ -71,10 +77,10 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
const pageId = url.searchParams.get('page');
|
||||
const accesses = {
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : null,
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -100,26 +106,28 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const prefix = 'doc-share-invitation';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-invitations');
|
||||
const list = page.getByLabel('List invitation card').locator('ul');
|
||||
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(list.locator('li')).toHaveCount(20);
|
||||
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
|
||||
const loadMoreButton = page
|
||||
.getByLabel('List invitation card')
|
||||
.getByRole('button', { name: 'arrow_downward Load more' });
|
||||
await loadMoreButton.scrollIntoViewIfNeeded();
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
|
||||
expect(await list.locator('li').count()).toBeGreaterThan(20);
|
||||
await expect(
|
||||
page.getByText(`impress@impress.world-page-1-16`).first(),
|
||||
list.getByText(`impress@impress.world-page-1-16`),
|
||||
).toBeVisible();
|
||||
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(
|
||||
page.getByText(`impress@impress.world-page-2-16`).first(),
|
||||
list.getByText(`impress@impress.world-page-2-15`),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(loadMore).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the role rules', async ({ page, browserName }) => {
|
||||
@@ -128,47 +136,59 @@ test.describe('Document list members', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
await expect(list).toBeVisible();
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user@chromium.e2e`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
await expect(currentUser).toBeVisible();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
await currentUserRole.click();
|
||||
const soloOwner = page.getByText(
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
|
||||
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
|
||||
|
||||
const soleOwner = list.getByText(
|
||||
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
|
||||
);
|
||||
await expect(soloOwner).toBeVisible();
|
||||
await list.click();
|
||||
const newUserEmail = await addNewMember(page, 0, 'Owner');
|
||||
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
|
||||
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
|
||||
|
||||
await expect(newUser).toBeVisible();
|
||||
await expect(soleOwner).toBeVisible();
|
||||
|
||||
await currentUserRole.click();
|
||||
await expect(soloOwner).toBeHidden();
|
||||
await list.click();
|
||||
const username = await addNewMember(page, 0, 'Owner');
|
||||
|
||||
const otherOwner = page.getByText(
|
||||
await expect(list.getByText(username)).toBeVisible();
|
||||
|
||||
await expect(soleOwner).toBeHidden();
|
||||
|
||||
const otherOwner = list.getByText(
|
||||
`You cannot update the role or remove other owner.`,
|
||||
);
|
||||
|
||||
await newUserRoles.click();
|
||||
await expect(otherOwner).toBeVisible();
|
||||
await list.click();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
const SelectRoleCurrentUser = list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: `user@${browserName}.e2e`,
|
||||
})
|
||||
.getByRole('combobox', { name: 'Role' });
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
await SelectRoleCurrentUser.click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
await expect(page.getByText('The role has been updated')).toBeVisible();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
// Admin still have the right to share
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).not.toHaveAttribute('disabled');
|
||||
|
||||
await SelectRoleCurrentUser.click();
|
||||
await page.getByRole('option', { name: 'Reader' }).click();
|
||||
await expect(page.getByText('The role has been updated')).toBeVisible();
|
||||
|
||||
// Reader does not have the right to share
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
test('it checks the delete members', async ({ page, browserName }) => {
|
||||
@@ -178,50 +198,43 @@ test.describe('Document list members', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
|
||||
const emailMyself = `user@${browserName}.e2e`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfMoreActions = mySelf.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
const nameMyself = `user@${browserName}.e2e`;
|
||||
await expect(list.getByText(nameMyself)).toBeVisible();
|
||||
|
||||
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
|
||||
const userOwner = list.getByTestId(
|
||||
`doc-share-member-row-${userOwnerEmail}`,
|
||||
);
|
||||
const userOwnerMoreActions = userOwner.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
const userOwner = await addNewMember(page, 0, 'Owner');
|
||||
await expect(list.getByText(userOwner)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share' }).first().click();
|
||||
const userReader = await addNewMember(page, 0, 'Reader');
|
||||
await expect(list.getByText(userReader)).toBeVisible();
|
||||
|
||||
const userReaderEmail = await addNewMember(page, 0, 'Reader');
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: userReader,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
|
||||
const userReader = list.getByTestId(
|
||||
`doc-share-member-row-${userReaderEmail}`,
|
||||
);
|
||||
const userReaderMoreActions = userReader.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await expect(list.getByText(userReader)).toBeHidden();
|
||||
|
||||
await expect(mySelf).toBeVisible();
|
||||
await expect(userOwner).toBeVisible();
|
||||
await expect(userReader).toBeVisible();
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: nameMyself,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
|
||||
await expect(userOwnerMoreActions).toBeVisible();
|
||||
await expect(userReaderMoreActions).toBeVisible();
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
await expect(list.getByText(nameMyself)).toBeHidden();
|
||||
|
||||
await userReaderMoreActions.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfMoreActions.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(
|
||||
page.getByText('You do not have permission to perform this action.'),
|
||||
page.getByText('The member has been removed from the document').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Share', level: 3 }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
type SmallDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Document search', () => {
|
||||
test('it checks all elements are visible', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'search' }).click();
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'No active search' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByLabel('Search modal').getByText('search'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByPlaceholder('Type the name of a document'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks search for a document', async ({ page, browserName }) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
|
||||
const doc1 = await createDoc(page, `My super ${id} doc`, browserName, 1);
|
||||
await verifyDocName(page, doc1[0]);
|
||||
await page.goto('/');
|
||||
const doc2 = await createDoc(
|
||||
page,
|
||||
`My super ${id} very doc`,
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc2[0]);
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'search' }).click();
|
||||
await page.getByPlaceholder('Type the name of a document').click();
|
||||
await page
|
||||
.getByPlaceholder('Type the name of a document')
|
||||
.fill(`My super ${id}`);
|
||||
|
||||
let responsePromisePage = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1&title=My+super+${id}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
let response = await responsePromisePage;
|
||||
let result = (await response.json()) as { results: SmallDoc[] };
|
||||
let docs = result.results;
|
||||
expect(docs.length).toEqual(2);
|
||||
|
||||
await Promise.all(
|
||||
docs.map(async (doc: SmallDoc) => {
|
||||
await expect(
|
||||
page.getByTestId(`doc-search-item-${doc.id}`),
|
||||
).toBeVisible();
|
||||
const updatedAt = DateTime.fromISO(doc.updated_at ?? DateTime.now())
|
||||
.setLocale('en')
|
||||
.toRelative();
|
||||
await expect(
|
||||
page.getByTestId(`doc-search-item-${doc.id}`).getByText(updatedAt!),
|
||||
).toBeVisible();
|
||||
}),
|
||||
);
|
||||
|
||||
const firstDoc = docs[0];
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`doc-search-item-${firstDoc.id}`)
|
||||
.getByText('keyboard_return'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Type the name of a document')
|
||||
.press('ArrowDown');
|
||||
|
||||
const secondDoc = docs[1];
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`doc-search-item-${secondDoc.id}`)
|
||||
.getByText('keyboard_return'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Type the name of a document').click();
|
||||
await page
|
||||
.getByPlaceholder('Type the name of a document')
|
||||
.fill(`My super ${id} doc`);
|
||||
|
||||
responsePromisePage = page.waitForResponse(
|
||||
(response) =>
|
||||
response
|
||||
.url()
|
||||
.includes(`/documents/?page=1&title=My+super+${id}+doc`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
response = await responsePromisePage;
|
||||
result = (await response.json()) as { results: SmallDoc[] };
|
||||
docs = result.results;
|
||||
|
||||
expect(docs.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,43 @@ test.describe('Doc Table Content', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror').click();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Level 1');
|
||||
await editor.getByText('Level 1').dblclick();
|
||||
await page.getByRole('button', { name: 'Strike' }).click();
|
||||
|
||||
await page.locator('.bn-block-outer').first().click();
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Level 2');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Level 3');
|
||||
|
||||
expect(true).toBe(true);
|
||||
|
||||
const summaryContainer = page.locator('#summaryContainer');
|
||||
await summaryContainer.click();
|
||||
await summaryContainer.hover();
|
||||
|
||||
const level1 = summaryContainer.getByText('Level 1');
|
||||
const level2 = summaryContainer.getByText('Level 2');
|
||||
|
||||
@@ -30,11 +30,9 @@ test.describe('Doc Version', () => {
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await modal.getByRole('button', { name: 'close' }).click();
|
||||
await editor.click();
|
||||
await page.keyboard.type('# Hello World');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
@@ -66,6 +64,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeHidden();
|
||||
const items = await panel.locator('.version-item').all();
|
||||
expect(items.length).toBe(1);
|
||||
|
||||
@@ -36,38 +36,42 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
const selectVisibility = page.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
});
|
||||
|
||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('option', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Visibility: Restricted', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('A doc is not accessible when not authenticated.', async ({
|
||||
test('A doc is not accessible when not authentified.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -98,7 +102,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('A doc is not accessible when authenticated but not member.', async ({
|
||||
test('A doc is not accessible when authentified but not member.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -139,9 +143,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
const otherBrowser = browsersName.find((b) => b !== browserName);
|
||||
const username = `user@${otherBrowser}.e2e`;
|
||||
@@ -149,11 +151,14 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await page.getByRole('option', { name: username }).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`User ${username} added to the document.`),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -172,7 +177,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,11 +201,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('option', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -209,18 +217,15 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reading',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Read only').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const cardContainer = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
@@ -246,10 +251,9 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('It checks a public doc in editable mode', async ({
|
||||
@@ -264,11 +268,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('option', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -277,14 +284,15 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const cardContainer = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
@@ -310,13 +318,16 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Visibility: Authenticated', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('A doc is not accessible when unauthenticated.', async ({
|
||||
test('A doc is not accessible when unauthentified.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -325,7 +336,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'Authenticated unauthenticated',
|
||||
'Authenticated unauthentified',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
@@ -333,11 +344,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -345,7 +359,9 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -380,11 +396,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -392,7 +411,9 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -409,13 +430,19 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(selectVisibility).toBeHidden();
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeHidden();
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(shareModal.getByLabel('List members card')).toBeHidden();
|
||||
});
|
||||
|
||||
test('It checks a authenticated doc in editable mode', async ({
|
||||
@@ -435,11 +462,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -447,15 +477,23 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -470,12 +508,18 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(selectVisibility).toBeHidden();
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeHidden();
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(shareModal.getByLabel('List members card')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { goToGridDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Footer', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
const footer = page.locator('footer').first();
|
||||
|
||||
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'info.gouv.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'service-public.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'data.gouv.fr' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'Legal Notice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'Personal data and cookies' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByRole('link', { name: 'Accessibility' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
footer.getByText(
|
||||
'Unless otherwise stated, all content on this site is under licence',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks footer is not visible on doc editor', async ({ page }) => {
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
await goToGridDoc(page);
|
||||
await expect(page.locator('footer')).toBeHidden();
|
||||
});
|
||||
|
||||
const legalPages = [
|
||||
{ name: 'Legal Notice', url: '/legal-notice/' },
|
||||
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
|
||||
{ name: 'Accessibility', url: '/accessibility/' },
|
||||
];
|
||||
for (const { name, url } of legalPages) {
|
||||
test(`checks ${name} page`, async ({ page }) => {
|
||||
const footer = page.locator('footer').first();
|
||||
await footer.getByRole('link', { name }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('heading', {
|
||||
name,
|
||||
})
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(url);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,9 @@ test.describe('Header mobile', () => {
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(header.getByLabel('Open the header menu')).toBeVisible();
|
||||
await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Docs Logo Docs' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.10.0",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -12,13 +12,11 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1",
|
||||
"@playwright/test": "1.49.0",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
"typescript": "*",
|
||||
"luxon": "3.5.0",
|
||||
"@types/luxon": "3.4.2"
|
||||
"typescript": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
|
||||
@@ -19,7 +19,6 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
maxFailures: process.env.CI ? 3 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 3 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
|
||||
@@ -12,36 +12,6 @@ const config = {
|
||||
'info-150': '#E5EEFA',
|
||||
'greyscale-000': '#fff',
|
||||
'greyscale-1000': '#161616',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
},
|
||||
font: {
|
||||
sizes: {
|
||||
|
||||
2
src/frontend/apps/impress/next-env.d.ts
vendored
2
src/frontend/apps/impress/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "1.10.0",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,40 +15,38 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.22.0",
|
||||
"@blocknote/mantine": "0.22.0",
|
||||
"@blocknote/react": "0.22.0",
|
||||
"@blocknote/core": "*",
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.0",
|
||||
"@hocuspocus/provider": "2.14.0",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@sentry/nextjs": "8.47.0",
|
||||
"@tanstack/react-query": "5.62.11",
|
||||
"cmdk": "1.0.4",
|
||||
"@sentry/nextjs": "8.40.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"i18next": "24.2.0",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"idb": "8.0.1",
|
||||
"i18next": "24.0.0",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"idb": "8.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.1.3",
|
||||
"next": "15.0.3",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-i18next": "15.1.1",
|
||||
"react-intersection-observer": "9.13.1",
|
||||
"react-select": "5.9.0",
|
||||
"react-select": "5.8.3",
|
||||
"styled-components": "6.1.13",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "13.6.21",
|
||||
"zustand": "5.0.2"
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.62.11",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.13",
|
||||
@@ -56,19 +54,19 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"cross-env": "*",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-impress": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "16.12.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "16.10.0",
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*",
|
||||
"webpack": "5.97.1",
|
||||
"webpack": "5.96.1",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 279 KiB |
@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check the versioning', () => {
|
||||
it('check the versionning', () => {
|
||||
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
|
||||
|
||||
void fetchAPI('some/url', {}, '2.0');
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
@@ -14,7 +8,7 @@ const StyledPopover = styled(Popover)`
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
border: 1px solid #dddddd;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -27,7 +21,6 @@ const StyledButton = styled(Button)`
|
||||
font-family: Marianne, Arial, serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
@@ -35,7 +28,6 @@ export interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DropButton = ({
|
||||
@@ -43,12 +35,10 @@ export const DropButton = ({
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
label,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const [opacity, setOpacity] = useState(false);
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -56,25 +46,21 @@ export const DropButton = ({
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
setTimeout(() => {
|
||||
setOpacity(isOpen);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
|
||||
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
export type DropdownMenuOption = {
|
||||
@@ -10,7 +10,6 @@ export type DropdownMenuOption = {
|
||||
testId?: string;
|
||||
callback?: () => void | Promise<unknown>;
|
||||
danger?: boolean;
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
show?: boolean;
|
||||
};
|
||||
@@ -18,9 +17,7 @@ export type DropdownMenuOption = {
|
||||
export type DropdownMenuProps = {
|
||||
options: DropdownMenuOption[];
|
||||
showArrow?: boolean;
|
||||
label?: string;
|
||||
arrowCss?: BoxProps['$css'];
|
||||
topMessage?: string;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
@@ -28,8 +25,6 @@ export const DropdownMenu = ({
|
||||
children,
|
||||
showArrow = false,
|
||||
arrowCss,
|
||||
label,
|
||||
topMessage,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
@@ -44,13 +39,11 @@ export const DropdownMenu = ({
|
||||
<DropButton
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
label={label}
|
||||
button={
|
||||
showArrow ? (
|
||||
<Box $direction="row" $align="center">
|
||||
<Box>
|
||||
<div>{children}</div>
|
||||
<Icon
|
||||
$variation="600"
|
||||
$css={
|
||||
arrowCss ??
|
||||
css`
|
||||
@@ -65,26 +58,15 @@ export const DropdownMenu = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box $maxWidth="320px">
|
||||
{topMessage && (
|
||||
<Text
|
||||
$variation="700"
|
||||
$wrap="wrap"
|
||||
$size="xs"
|
||||
$weight="bold"
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
>
|
||||
{topMessage}
|
||||
</Text>
|
||||
)}
|
||||
<Box>
|
||||
{options.map((option, index) => {
|
||||
if (option.show !== undefined && !option.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDisabled = option.disabled !== undefined && option.disabled;
|
||||
return (
|
||||
<BoxButton
|
||||
aria-label={option.label}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
disabled={isDisabled}
|
||||
@@ -96,7 +78,6 @@ export const DropdownMenu = ({
|
||||
}}
|
||||
key={option.label}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$background={colors['greyscale-000']}
|
||||
$color={colors['primary-600']}
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
@@ -104,43 +85,29 @@ export const DropdownMenu = ({
|
||||
$gap={spacings['base']}
|
||||
$css={css`
|
||||
border: none;
|
||||
${index === 0 &&
|
||||
css`
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
`}
|
||||
${index === options.length - 1 &&
|
||||
css`
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
`}
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
color: var(--c--theme--colors--primary-600);
|
||||
font-weight: 500;
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
user-select: none;
|
||||
border-bottom: ${index !== options.length - 1
|
||||
? `1px solid var(--c--theme--colors--greyscale-200)`
|
||||
: 'none'};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap={spacings['base']}>
|
||||
{option.icon && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$variation={isDisabled ? '400' : '1000'}
|
||||
iconName={option.icon}
|
||||
/>
|
||||
)}
|
||||
<Text $variation={isDisabled ? '400' : '1000'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{option.isSelected && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
{option.icon && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme={!isDisabled ? 'primary' : 'greyscale'}
|
||||
$variation={!isDisabled ? '600' : '400'}
|
||||
iconName={option.icon}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</BoxButton>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
|
||||
type LoadMoreTextProps = {
|
||||
['data-testid']?: string;
|
||||
};
|
||||
|
||||
export const LoadMoreText = ({
|
||||
'data-testid': dataTestId,
|
||||
}: LoadMoreTextProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid={dataTestId}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.4rem"
|
||||
$padding={{ horizontal: '2xs', vertical: 'sm' }}
|
||||
>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
iconName="arrow_downward"
|
||||
$size="md"
|
||||
/>
|
||||
<Text $theme="primary" $variation="800">
|
||||
{t('Load more')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,8 +6,7 @@ export * from './DropdownMenu';
|
||||
export * from './Icon';
|
||||
export * from './InfiniteScroll';
|
||||
export * from './Link';
|
||||
export * from './LoadMoreText';
|
||||
export * from './SideModal';
|
||||
export * from './separators';
|
||||
export * from './separators/SeparatedSection';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
|
||||
import { hasChildrens } from '@/utils/children';
|
||||
|
||||
import { Box } from '../Box';
|
||||
|
||||
import { QuickSearchInput } from './QuickSearchInput';
|
||||
import { QuickSearchStyle } from './QuickSearchStyle';
|
||||
|
||||
export type QuickSearchAction = {
|
||||
onSelect?: () => void;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export type QuickSearchData<T> = {
|
||||
groupName: string;
|
||||
elements: T[];
|
||||
emptyString?: string;
|
||||
startActions?: QuickSearchAction[];
|
||||
endActions?: QuickSearchAction[];
|
||||
showWhenEmpty?: boolean;
|
||||
};
|
||||
|
||||
export type QuickSearchProps = {
|
||||
onFilter?: (str: string) => void;
|
||||
inputValue?: string;
|
||||
inputContent?: ReactNode;
|
||||
showInput?: boolean;
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const QuickSearch = ({
|
||||
onFilter,
|
||||
inputContent,
|
||||
inputValue,
|
||||
loading,
|
||||
showInput = true,
|
||||
label,
|
||||
placeholder,
|
||||
children,
|
||||
}: QuickSearchProps) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickSearchStyle />
|
||||
<div className="quick-search-container">
|
||||
<Command label={label} shouldFilter={false} ref={ref}>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
loading={loading}
|
||||
withSeparator={hasChildrens(children)}
|
||||
inputValue={inputValue}
|
||||
onFilter={onFilter}
|
||||
placeholder={placeholder}
|
||||
>
|
||||
{inputContent}
|
||||
</QuickSearchInput>
|
||||
)}
|
||||
<Command.List>
|
||||
<Box>{children}</Box>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Box } from '../Box';
|
||||
|
||||
import { QuickSearchData } from './QuickSearch';
|
||||
import { QuickSearchItem } from './QuickSearchItem';
|
||||
|
||||
type Props<T> = {
|
||||
group: QuickSearchData<T>;
|
||||
renderElement?: (element: T) => ReactNode;
|
||||
onSelect?: (element: T) => void;
|
||||
};
|
||||
|
||||
export const QuickSearchGroup = <T,>({
|
||||
group,
|
||||
onSelect,
|
||||
renderElement,
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<Box $margin={{ top: 'base' }}>
|
||||
<Command.Group
|
||||
key={group.groupName}
|
||||
heading={group.groupName}
|
||||
forceMount={false}
|
||||
>
|
||||
{group.startActions?.map((action, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
key={`${group.groupName}-action-${index}`}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.content}
|
||||
</QuickSearchItem>
|
||||
);
|
||||
})}
|
||||
{group.elements.map((groupElement, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
id={`${group.groupName}-element-${index}`}
|
||||
key={`${group.groupName}-element-${index}`}
|
||||
onSelect={() => {
|
||||
onSelect?.(groupElement);
|
||||
}}
|
||||
>
|
||||
{renderElement?.(groupElement)}
|
||||
</QuickSearchItem>
|
||||
);
|
||||
})}
|
||||
{group.endActions?.map((action, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
key={`${group.groupName}-action-${index}`}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.content}
|
||||
</QuickSearchItem>
|
||||
);
|
||||
})}
|
||||
{group.emptyString && group.elements.length === 0 && (
|
||||
<span className="ml-b clr-greyscale-500">{group.emptyString}</span>
|
||||
)}
|
||||
</Command.Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { HorizontalSeparator } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
type Props = {
|
||||
loading?: boolean;
|
||||
inputValue?: string;
|
||||
onFilter?: (str: string) => void;
|
||||
placeholder?: string;
|
||||
children?: ReactNode;
|
||||
withSeparator?: boolean;
|
||||
};
|
||||
export const QuickSearchInput = ({
|
||||
loading,
|
||||
inputValue,
|
||||
onFilter,
|
||||
placeholder,
|
||||
children,
|
||||
withSeparator: separator = true,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{separator && <HorizontalSeparator />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="quick-search-input"
|
||||
$gap={spacing['2xs']}
|
||||
$padding={{ all: 'base' }}
|
||||
>
|
||||
{!loading && <Icon iconName="search" $variation="600" />}
|
||||
{loading && (
|
||||
<div>
|
||||
<Loader size="small" />
|
||||
</div>
|
||||
)}
|
||||
<Command.Input
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
value={inputValue}
|
||||
role="combobox"
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
onValueChange={onFilter}
|
||||
/>
|
||||
</Box>
|
||||
{separator && <HorizontalSeparator $withPadding={false} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
type Props = {
|
||||
onSelect?: (value: string) => void;
|
||||
id?: string;
|
||||
};
|
||||
export const QuickSearchItem = ({
|
||||
children,
|
||||
onSelect,
|
||||
id,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
return (
|
||||
<Command.Item value={id} onSelect={onSelect}>
|
||||
{children}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { Box } from '../Box';
|
||||
|
||||
export type QuickSearchItemContentProps = {
|
||||
alwaysShowRight?: boolean;
|
||||
left: ReactNode;
|
||||
right?: ReactNode;
|
||||
};
|
||||
|
||||
export const QuickSearchItemContent = ({
|
||||
alwaysShowRight = false,
|
||||
left,
|
||||
right,
|
||||
}: QuickSearchItemContentProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacings = spacingsTokens();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$padding={{ horizontal: '2xs', vertical: '3xs' }}
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacings['2xs']}
|
||||
$width="100%"
|
||||
>
|
||||
{left}
|
||||
</Box>
|
||||
|
||||
{isDesktop && right && (
|
||||
<Box
|
||||
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
{right}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const QuickSearchStyle = createGlobalStyle`
|
||||
.quick-search-container {
|
||||
[cmdk-root] {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 100ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[cmdk-input] {
|
||||
border: none;
|
||||
width: 100%;
|
||||
font-size: 17px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
border-radius: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[cmdk-item] {
|
||||
content-visibility: auto;
|
||||
cursor: pointer;
|
||||
border-radius: var(--c--theme--spacings--xs);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
will-change: background, color;
|
||||
transition: all 150ms ease;
|
||||
transition-property: none;
|
||||
|
||||
.show-right-on-focus {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-selected='true'] {
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
.show-right-on-focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
& + [cmdk-item] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-list] {
|
||||
|
||||
padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base)
|
||||
var(--c--theme--spacings--base);
|
||||
|
||||
flex:1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[cmdk-vercel-shortcuts] {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
|
||||
kbd {
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
padding: 4px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
*:not([hidden]) + [cmdk-group] {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
user-select: none;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--c--theme--spacings--xs);
|
||||
}
|
||||
|
||||
[cmdk-empty] {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.c__modal__scroller:has(.quick-search-container),
|
||||
.c__modal__scroller:has(.noPadding) {
|
||||
padding: 0 !important;
|
||||
|
||||
.c__modal__close .c__button {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.c__modal__title {
|
||||
font-size: var(--c--theme--font--sizes--xs);
|
||||
|
||||
padding: var(--c--theme--spacings--base);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './QuickSearch';
|
||||
export * from './QuickSearchGroup';
|
||||
export * from './QuickSearchItem';
|
||||
export * from './QuickSearchItemContent';
|
||||
@@ -19,8 +19,7 @@ export const SeparatedSection = ({
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
width: 100%;
|
||||
padding: ${spacings['sm']} 0;
|
||||
padding: ${spacings['base']} 0;
|
||||
${showSeparator &&
|
||||
css`
|
||||
border-bottom: 1px solid ${colors?.['greyscale-200']};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user