mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
185 Commits
v1.7.0-pre
...
fix-warnin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e1e8032f | ||
|
|
a4cd6f4dfb | ||
|
|
9ccfb3183a | ||
|
|
8655ffa8c2 | ||
|
|
194ac8e856 | ||
|
|
bc67d1b978 | ||
|
|
a32036ba8c | ||
|
|
30aab3fb9d | ||
|
|
954ae0e510 | ||
|
|
a090f180f4 | ||
|
|
c7e543d459 | ||
|
|
23e6b508f8 | ||
|
|
49a3989977 | ||
|
|
8eb2b60937 | ||
|
|
f02dcae52a | ||
|
|
098df5c0b5 | ||
|
|
684b77cbe6 | ||
|
|
81e9fc49fe | ||
|
|
7c696fc1ec | ||
|
|
fc27043e9e | ||
|
|
6ad1e27acf | ||
|
|
899047d9a2 | ||
|
|
78b5e2c1cc | ||
|
|
72f234027c | ||
|
|
730efe7b74 | ||
|
|
63885117e1 | ||
|
|
4f4c8905ff | ||
|
|
a5f6cb542d | ||
|
|
8456f47260 | ||
|
|
eb35fdc7a9 | ||
|
|
ceaf1e28f9 | ||
|
|
c3da23f5d3 | ||
|
|
44784b2236 | ||
|
|
157f6200f2 | ||
|
|
2882348547 | ||
|
|
e016cfab70 | ||
|
|
23b11e4096 | ||
|
|
7696872416 | ||
|
|
42d9fa70a2 | ||
|
|
a8a89def98 | ||
|
|
5bcce0c64a | ||
|
|
3a738fe701 | ||
|
|
d5670640f5 | ||
|
|
1d85eee78f | ||
|
|
5a46ab0055 | ||
|
|
3d5ff93a51 | ||
|
|
b9c66c7c2a | ||
|
|
68a390ef59 | ||
|
|
192ab1121c | ||
|
|
83eb33d54a | ||
|
|
ee937de2c4 | ||
|
|
8d514bd571 | ||
|
|
e83c404e21 | ||
|
|
945f55f50d | ||
|
|
9f83ea7111 | ||
|
|
f12c06e975 | ||
|
|
bbb176e153 | ||
|
|
02793040fd | ||
|
|
0773e83149 | ||
|
|
21205b4d19 | ||
|
|
60dbf6c11d | ||
|
|
2491ad7142 | ||
|
|
3b2834cf6d | ||
|
|
7ed2b23ea3 | ||
|
|
c879f82114 | ||
|
|
02a4740c66 | ||
|
|
6cb2702e6b | ||
|
|
94a9f7a84e | ||
|
|
e53465ce11 | ||
|
|
33d1f3c151 | ||
|
|
fc4eba2497 | ||
|
|
3e5f27c1d5 | ||
|
|
f2f64f7dd6 | ||
|
|
d842800df3 | ||
|
|
1af2ad0ec4 | ||
|
|
67915151aa | ||
|
|
de25b36a01 | ||
|
|
59e74e6eeb | ||
|
|
4e7f095b0f | ||
|
|
cdea75b87f | ||
|
|
6a0d2e21b5 | ||
|
|
b79d5fccbc | ||
|
|
6d77cb1801 | ||
|
|
e4a45a556c | ||
|
|
3ca39ceb8a | ||
|
|
8a93122882 | ||
|
|
8eb986591a | ||
|
|
c10808b611 | ||
|
|
ba63358098 | ||
|
|
52534db3e1 | ||
|
|
dc9b375ff5 | ||
|
|
65fdf115be | ||
|
|
ecb2b35ec8 | ||
|
|
2d13e0985e | ||
|
|
5014443f80 | ||
|
|
3fef7596b3 | ||
|
|
19042907be | ||
|
|
5cdd06d432 | ||
|
|
47e23bff90 | ||
|
|
7dfc62b2c5 | ||
|
|
39c4af0a7c | ||
|
|
57c5c394f5 | ||
|
|
be6da38a08 | ||
|
|
fc36ed08f1 | ||
|
|
ed90769081 | ||
|
|
a8310fa0ff | ||
|
|
a902e31521 | ||
|
|
932ab13d97 | ||
|
|
94a1ba7989 | ||
|
|
bfecdbf83a | ||
|
|
ba1cfc3c27 | ||
|
|
2cba228a67 | ||
|
|
66553ee236 | ||
|
|
64674b6a73 | ||
|
|
a9def8cb18 | ||
|
|
69186e9a26 | ||
|
|
f606826098 | ||
|
|
aff036d9fb | ||
|
|
57ed08994b | ||
|
|
131eefa1ac | ||
|
|
b4e639cc24 | ||
|
|
ba962af914 | ||
|
|
76514a6e2b | ||
|
|
b69a5342d9 | ||
|
|
c25682f199 | ||
|
|
eec8b4d2c3 | ||
|
|
1af7b797bc | ||
|
|
b5c159bf63 | ||
|
|
bfbdfb2b5c | ||
|
|
08bb64ddc1 | ||
|
|
23f90156bf | ||
|
|
1899cff572 | ||
|
|
774c2ce248 | ||
|
|
89d9075850 | ||
|
|
2c915d53f4 | ||
|
|
797d9442ac | ||
|
|
573d054748 | ||
|
|
2035a256f5 | ||
|
|
c94f26c8b9 | ||
|
|
fc2f14b3f4 | ||
|
|
6dd1697915 | ||
|
|
79e899c301 | ||
|
|
2194301716 | ||
|
|
0348894ab8 | ||
|
|
9b17d8bea1 | ||
|
|
69d6b6f934 | ||
|
|
6c106374fa | ||
|
|
af039d045d | ||
|
|
4c9caf09ba | ||
|
|
3fd02adbec | ||
|
|
90dac3cd15 | ||
|
|
d0307ee6d9 | ||
|
|
09d02b7ced | ||
|
|
56a26d9663 | ||
|
|
42f809f6d4 | ||
|
|
7d64c82987 | ||
|
|
6252227bb6 | ||
|
|
e9ac393a8f | ||
|
|
5b1745f991 | ||
|
|
0e55bf5c43 | ||
|
|
9f66f73501 | ||
|
|
c3da28b07f | ||
|
|
b035b96dec | ||
|
|
9623ac4141 | ||
|
|
c8edbd285b | ||
|
|
016597d5a2 | ||
|
|
52dea8fa2f | ||
|
|
0a37a8ea6d | ||
|
|
c1404ef904 | ||
|
|
2c0fce61df | ||
|
|
bbe9b6b6cf | ||
|
|
23231563c9 | ||
|
|
d75c8668c5 | ||
|
|
f266232b5a | ||
|
|
a8362e8e88 | ||
|
|
e4dfae1905 | ||
|
|
a09e740648 | ||
|
|
5ee6a43f08 | ||
|
|
8bd83cbfcd | ||
|
|
bc14d1d0f8 | ||
|
|
526e649f06 | ||
|
|
ac40eb8f7c | ||
|
|
c750cf10a8 | ||
|
|
4f4951cdcd | ||
|
|
50891afd05 |
96
.github/workflows/docker-hub.yml
vendored
96
.github/workflows/docker-hub.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
@@ -16,26 +19,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -45,13 +31,14 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -66,26 +53,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -95,13 +65,14 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -117,26 +88,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -146,19 +100,20 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
|
||||
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -173,29 +128,12 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
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 ''${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
|
||||
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 }}
|
||||
|
||||
30
.github/workflows/helmfile-linter.yaml
vendored
Normal file
30
.github/workflows/helmfile-linter.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
45
.github/workflows/impress-frontend.yml
vendored
45
.github/workflows/impress-frontend.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -46,6 +46,11 @@ 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
|
||||
@@ -54,7 +59,7 @@ jobs:
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
run: cd src/frontend/ && yarn test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -90,16 +95,38 @@ 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=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
||||
# 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: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-chromium-report
|
||||
@@ -124,16 +151,16 @@ 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
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-other-report
|
||||
|
||||
6
.github/workflows/impress.yml
vendored
6
.github/workflows/impress.yml
vendored
@@ -107,7 +107,9 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12.6"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -199,7 +201,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12.6"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
34
.github/workflows/release-helm-chart.yaml
vendored
Normal file
34
.github/workflows/release-helm-chart.yaml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
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
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -9,6 +9,114 @@ 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) 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) 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
|
||||
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
|
||||
- 🌐(backend) add German translation #259
|
||||
- 🌐(frontend) add German translation #255
|
||||
- ✨(frontend) add a broadcast store #387
|
||||
- ✨(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) improve users similarity search and sort results #391
|
||||
- ♻️(frontend) simplify stores #402
|
||||
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
|
||||
- ✅(CI) trivy continue on error #453
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🔧(backend) fix logging for docker and make it configurable by envar #427
|
||||
- 🦺(backend) add comma to sub regex #408
|
||||
- 🐛(editor) collaborative user tag hidden when read only #385
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
@@ -89,7 +197,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 unauthentified #292
|
||||
- 🛂(frontend) redirect to the OIDC when private doc and unauthenticated #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
|
||||
@@ -218,7 +326,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 occurences of pad to doc (#99)
|
||||
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -238,7 +346,12 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
|
||||
[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
|
||||
[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
|
||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
|
||||
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 nginx
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@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/Dockerfile',
|
||||
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
live_update=[
|
||||
|
||||
@@ -1,103 +1,2 @@
|
||||
#!/bin/sh
|
||||
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"}]'
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress
|
||||
|
||||
@@ -118,6 +118,7 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
@@ -158,15 +159,13 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
dockerfile: ./src/frontend/servers/y-provider/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,9 +4,58 @@ 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 /auth;
|
||||
auth_request /media-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;
|
||||
@@ -21,8 +70,8 @@ server {
|
||||
proxy_set_header Host minio:9000;
|
||||
}
|
||||
|
||||
location /auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
|
||||
location /media-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/media-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;
|
||||
|
||||
156
docs/examples/impress.values.yaml
Normal file
156
docs/examples/impress.values.yaml
Normal file
@@ -0,0 +1,156 @@
|
||||
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
|
||||
|
||||
2299
docs/examples/keycloak.values.yaml
Normal file
2299
docs/examples/keycloak.values.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/examples/minio.values.yaml
Normal file
8
docs/examples/minio.values.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
auth:
|
||||
rootUser: root
|
||||
rootPassword: password
|
||||
provisioning:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: impress-media-storage
|
||||
versioning: true
|
||||
7
docs/examples/postgresql.values.yaml
Normal file
7
docs/examples/postgresql.values.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: impress
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
4
docs/examples/redis.values.yaml
Normal file
4
docs/examples/redis.values.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
231
docs/installation.md
Normal file
231
docs/installation.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# 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 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 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 Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -4,13 +4,21 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Logging
|
||||
# Set to DEBUG level for dev only
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
|
||||
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||
|
||||
# Python
|
||||
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
|
||||
@@ -21,6 +29,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=impress
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
||||
@@ -44,3 +53,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
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/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=dsfr
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# 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,7 +13,13 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
"matchPackageNames": [
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"eslint",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
secrets
1
secrets
Submodule secrets deleted from 38594182e8
69
src/backend/core/api/filters.py
Normal file
69
src/backend/core/api/filters.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""API filters for Impress' core application."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class DocumentFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on the `creator` being the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_creator_me=true
|
||||
→ Filters documents created by the logged-in user
|
||||
- /api/v1.0/documents/?is_creator_me=false
|
||||
→ Filters documents created by other users
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(creator=user)
|
||||
|
||||
return queryset.exclude(creator=user)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_favorite(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are marked as favorite by the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_favorite=true
|
||||
→ Filters documents marked as favorite by the logged-in user
|
||||
- /api/v1.0/documents/?is_favorite=false
|
||||
→ Filters documents not marked as favorite by the logged-in user
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(favorited_by_users__user=user)
|
||||
|
||||
return queryset.exclude(favorited_by_users__user=user)
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -11,6 +12,10 @@ 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):
|
||||
@@ -137,32 +142,69 @@ class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
return {}
|
||||
|
||||
|
||||
class DocumentSerializer(BaseResourceSerializer):
|
||||
"""Serialize documents."""
|
||||
class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
accesses = DocumentAccessSerializer(many=True, read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"content",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"created_at",
|
||||
"nb_accesses",
|
||||
"title",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"title",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_avorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -190,6 +232,96 @@ class DocumentSerializer(BaseResourceSerializer):
|
||||
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,57 +1,58 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
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 (
|
||||
Min,
|
||||
Count,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
)
|
||||
from django.http import Http404
|
||||
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
metadata,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import filters, status
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import enums, models
|
||||
from core import authentication, 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_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
|
||||
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
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})$"
|
||||
)
|
||||
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
ATTACHMENTS_FOLDER = "attachments"
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
class NestedGenericViewSet(drf.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>/`
|
||||
@@ -123,7 +124,7 @@ class SerializerPerActionMixin:
|
||||
return self.serializer_classes.get(self.action, self.default_serializer_class)
|
||||
|
||||
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
class Pagination(drf.pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
@@ -132,13 +133,14 @@ class Pagination(pagination.PageNumberPagination):
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -156,11 +158,24 @@ class UserViewSet(
|
||||
|
||||
# Filter users by email similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
# For performance reasons we filter first by similarity, which relies on an index,
|
||||
# then only calculate precise similarity scores for sorting purposes
|
||||
queryset = queryset.filter(email__trigram_word_similar=query)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
similarity=TrigramSimilarity("email", query)
|
||||
)
|
||||
# When the query only is on the name part, we should try to make many proposals
|
||||
# But when the query looks like an email we should only propose serious matches
|
||||
threshold = 0.6 if "@" in query else 0.1
|
||||
|
||||
queryset = queryset.filter(similarity__gt=threshold).order_by(
|
||||
"-similarity", "email"
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@decorators.action(
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
@@ -172,47 +187,11 @@ 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
|
||||
)
|
||||
|
||||
|
||||
class ResourceViewsetMixin:
|
||||
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related resources."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
self.access_model_class.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
**{self.resource_field_name: OuterRef("pk")},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
obj = serializer.save()
|
||||
self.access_model_class.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.resource_field_name: obj},
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewsetMixin:
|
||||
"""Mixin with methods common to all access viewsets."""
|
||||
|
||||
@@ -243,7 +222,7 @@ class ResourceAccessViewsetMixin:
|
||||
teams = user.teams
|
||||
user_roles_query = (
|
||||
queryset.filter(
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
db.Q(user=user) | db.Q(team__in=teams),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
@@ -257,11 +236,13 @@ class ResourceAccessViewsetMixin:
|
||||
# access instances pointing to the logged-in user)
|
||||
queryset = (
|
||||
queryset.filter(
|
||||
Q(**{f"{self.resource_field_name}__accesses__user": user})
|
||||
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
|
||||
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
|
||||
| db.Q(
|
||||
**{f"{self.resource_field_name}__accesses__team__in": teams}
|
||||
),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
)
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
.annotate(user_roles=db.Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
@@ -276,9 +257,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=status.HTTP_403_FORBIDDEN,
|
||||
status=drf.status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -299,12 +280,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 exceptions.PermissionDenied({"detail": message})
|
||||
raise drf.exceptions.PermissionDenied({"detail": message})
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class DocumentMetadata(metadata.SimpleMetadata):
|
||||
class DocumentMetadata(drf.metadata.SimpleMetadata):
|
||||
"""Custom metadata class to add information"""
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
@@ -322,35 +303,90 @@ class DocumentMetadata(metadata.SimpleMetadata):
|
||||
|
||||
|
||||
class DocumentViewSet(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""Document ViewSet"""
|
||||
"""
|
||||
Document ViewSet for managing documents.
|
||||
|
||||
Provides endpoints for creating, updating, and deleting documents,
|
||||
along with filtering options.
|
||||
|
||||
Filtering:
|
||||
- `is_creator_me=true`: Returns documents created by the current user.
|
||||
- `is_creator_me=false`: Returns documents created by other users.
|
||||
- `is_favorite=true`: Returns documents marked as favorite by the current user
|
||||
- `is_favorite=false`: Returns documents not marked as favorite by the current user
|
||||
- `title=hello`: Returns documents which title contains the "hello" string
|
||||
|
||||
Example Usage:
|
||||
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
|
||||
permission_classes = [
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
access_model_class = models.DocumentAccess
|
||||
resource_field_name = "document"
|
||||
queryset = models.Document.objects.all()
|
||||
ordering = ["-updated_at"]
|
||||
metadata_class = DocumentMetadata
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
|
||||
"""
|
||||
if self.action == "list":
|
||||
return serializers.ListDocumentSerializer
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queryset to include favorite status for the current user."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
# Annotate the number of accesses associated with each document
|
||||
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
|
||||
|
||||
if not user.is_authenticated:
|
||||
# If the user is not authenticated, annotate `is_favorite` as False
|
||||
return queryset.annotate(is_favorite=Value(False))
|
||||
|
||||
# Annotate the queryset to indicate if the document is favorited by the current user
|
||||
favorite_exists = models.DocumentFavorite.objects.filter(
|
||||
document_id=OuterRef("pk"), user=user
|
||||
)
|
||||
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
|
||||
|
||||
# Annotate the queryset with the logged-in user roles
|
||||
user_roles_query = (
|
||||
models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document_id=OuterRef("pk"),
|
||||
)
|
||||
.values("document")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict resources returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
| (
|
||||
Q(link_traces__user=user)
|
||||
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
db.Q(link_traces__user=user)
|
||||
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -362,7 +398,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):
|
||||
"""
|
||||
@@ -385,9 +421,42 @@ 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)
|
||||
|
||||
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator and owner of the newly created object."""
|
||||
obj = serializer.save(creator=self.request.user)
|
||||
models.DocumentAccess.objects.create(
|
||||
document=obj,
|
||||
user=self.request.user,
|
||||
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")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return the document's versions but only those created after the user got access
|
||||
@@ -395,7 +464,7 @@ class DocumentViewSet(
|
||||
"""
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
raise drf.exceptions.PermissionDenied("Authentication required.")
|
||||
|
||||
# Validate query parameters using dedicated serializer
|
||||
serializer = serializers.VersionFilterSerializer(data=request.query_params)
|
||||
@@ -406,13 +475,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(
|
||||
Q(user=user) | Q(team__in=user.teams)
|
||||
).aggregate(min_date=Min("created_at"))
|
||||
db.Q(user=user) | db.Q(team__in=user.teams)
|
||||
).aggregate(min_date=db.Min("created_at"))
|
||||
|
||||
# Handle the case where the user has no accesses
|
||||
min_datetime = access_queryset["min_date"]
|
||||
if not min_datetime:
|
||||
return exceptions.PermissionDenied(
|
||||
return drf.exceptions.PermissionDenied(
|
||||
"Only users with specific access can see version history"
|
||||
)
|
||||
|
||||
@@ -422,9 +491,9 @@ class DocumentViewSet(
|
||||
page_size=serializer.validated_data.get("page_size"),
|
||||
)
|
||||
|
||||
return drf_response.Response(versions_data)
|
||||
return drf.response.Response(versions_data)
|
||||
|
||||
@decorators.action(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
|
||||
@@ -445,7 +514,7 @@ class DocumentViewSet(
|
||||
min_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
db.Q(user=user) | db.Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
if response["LastModified"] < min_datetime:
|
||||
@@ -453,11 +522,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"],
|
||||
@@ -465,7 +534,7 @@ class DocumentViewSet(
|
||||
}
|
||||
)
|
||||
|
||||
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
|
||||
@drf.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
|
||||
@@ -478,9 +547,50 @@ class DocumentViewSet(
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
serializer.save()
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
|
||||
# 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")
|
||||
def favorite(self, request, *args, **kwargs):
|
||||
"""
|
||||
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
|
||||
"""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
|
||||
if request.method == "POST":
|
||||
# Try to mark as favorite
|
||||
try:
|
||||
models.DocumentFavorite.objects.create(document=document, user=user)
|
||||
except ValidationError:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document already marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
return drf.response.Response(
|
||||
{"detail": "Document marked as favorite"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# Handle DELETE method to unmark as favorite
|
||||
deleted, _ = models.DocumentFavorite.objects.filter(
|
||||
document=document, user=user
|
||||
).delete()
|
||||
if deleted:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document unmarked as favorite"},
|
||||
status=drf.status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already not marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@drf.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
|
||||
@@ -505,53 +615,128 @@ class DocumentViewSet(
|
||||
file, default_storage.bucket_name, key, ExtraArgs=extra_args
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
|
||||
return drf.response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
|
||||
def retrieve_auth(self, request, *args, **kwargs):
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a document's
|
||||
attachment file.
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
|
||||
match = MEDIA_URL_PATTERN.search(original_url.path)
|
||||
url_params, _, _ = self._authorize_subrequest(
|
||||
request, MEDIA_STORAGE_URL_PATTERN
|
||||
)
|
||||
pk, key = url_params.values()
|
||||
|
||||
try:
|
||||
pk, attachment_key = match.groups()
|
||||
except AttributeError as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
|
||||
|
||||
# Check permission
|
||||
try:
|
||||
document = models.Document.objects.get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
return drf.response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
if not document.get_abilities(request.user).get("retrieve", False):
|
||||
raise exceptions.PermissionDenied()
|
||||
@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"]
|
||||
|
||||
# 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)
|
||||
# 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),
|
||||
}
|
||||
|
||||
@decorators.action(
|
||||
return drf.response.Response("authorized", headers=headers, status=200)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Apply a transformation action on a piece of text with AI",
|
||||
@@ -577,9 +762,9 @@ class DocumentViewSet(
|
||||
|
||||
response = AIService().transform(text, action)
|
||||
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Translate a piece of text with AI",
|
||||
@@ -606,17 +791,17 @@ class DocumentViewSet(
|
||||
|
||||
response = AIService().translate(text, language)
|
||||
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with document accesses.
|
||||
@@ -650,46 +835,87 @@ class DocumentAccessViewSet(
|
||||
serializer_class = serializers.DocumentAccessSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
"""Add new access to the document and email the new added user."""
|
||||
access = serializer.save()
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
access.document.email_invitation(
|
||||
language,
|
||||
access.document.send_invitation_email(
|
||||
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(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
|
||||
filter_backends = [drf.filters.OrderingFilter]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
access_model_class = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related templates."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
models.TemplateAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
template_id=OuterRef("pk"),
|
||||
)
|
||||
.values("template")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
| Q(is_public=True)
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
| db.Q(is_public=True)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
@@ -700,9 +926,18 @@ 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)
|
||||
|
||||
@decorators.action(
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
obj = serializer.save()
|
||||
models.TemplateAccess.objects.create(
|
||||
template=obj,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
@@ -725,8 +960,8 @@ class TemplateViewSet(
|
||||
serializer = serializers.DocumentGenerationSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
return drf.response.Response(
|
||||
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
body = serializer.validated_data["body"]
|
||||
@@ -739,12 +974,12 @@ class TemplateViewSet(
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with template accesses.
|
||||
@@ -779,12 +1014,12 @@ class TemplateAccessViewSet(
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to document.
|
||||
|
||||
@@ -836,7 +1071,7 @@ class InvitationViewset(
|
||||
# Determine which role the logged-in user has in the document
|
||||
user_roles_query = (
|
||||
models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
db.Q(user=user) | db.Q(team__in=teams),
|
||||
document=self.kwargs["resource_id"],
|
||||
)
|
||||
.values("document")
|
||||
@@ -847,28 +1082,56 @@ class InvitationViewset(
|
||||
queryset = (
|
||||
# The logged-in user should be administrator or owner to see its accesses
|
||||
queryset.filter(
|
||||
Q(
|
||||
db.Q(
|
||||
document__accesses__user=user,
|
||||
document__accesses__role__in=models.PRIVILEGED_ROLES,
|
||||
)
|
||||
| Q(
|
||||
| db.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=Subquery(user_roles_query))
|
||||
.annotate(user_roles=db.Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
"""Save invitation to a document then email the invited user."""
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
invitation.document.email_invitation(
|
||||
language, invitation.email, invitation.role, self.request.user
|
||||
invitation.document.send_invitation_email(
|
||||
invitation.email, invitation.role, self.request.user, language
|
||||
)
|
||||
|
||||
|
||||
class ConfigView(drf.views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"COLLABORATION_WS_URL",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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,5 +1,7 @@
|
||||
"""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 _
|
||||
@@ -11,6 +13,8 @@ from mozilla_django_oidc.auth import (
|
||||
|
||||
from core.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -59,10 +63,29 @@ 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
|
||||
@@ -75,12 +98,6 @@ 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:
|
||||
@@ -101,15 +118,13 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return User.objects.filter(email=email).first()
|
||||
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
@@ -119,4 +134,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
@@ -14,5 +14,5 @@ urlpatterns = [
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
*mozilla_oidc_urls,
|
||||
]
|
||||
|
||||
@@ -19,6 +19,7 @@ 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")
|
||||
@@ -36,6 +37,8 @@ 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):
|
||||
"""
|
||||
@@ -45,6 +48,8 @@ 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"""
|
||||
@@ -56,6 +61,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
title = factory.Sequence(lambda n: f"document{n}")
|
||||
content = factory.Sequence(lambda n: f"content{n}")
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
link_reach = factory.fuzzy.FuzzyChoice(
|
||||
[a[0] for a in models.LinkReachChoices.choices]
|
||||
)
|
||||
@@ -80,6 +86,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def favorited_by(self, create, extracted, **kwargs):
|
||||
"""Mark document as favorited by a list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# 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
|
||||
@@ -145,7 +143,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
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.'),
|
||||
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.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
@@ -161,6 +159,6 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
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.'),
|
||||
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.'),
|
||||
),
|
||||
]
|
||||
|
||||
37
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
37
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-08 07:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_document_link_reach'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentFavorite',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document favorite',
|
||||
'verbose_name_plural': 'Document favorites',
|
||||
'db_table': 'impress_document_favorite',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-09 11:36
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_document_favorite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', 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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='sub',
|
||||
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-09 11:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
|
||||
|
||||
|
||||
def set_creator_from_document_access(apps, schema_editor):
|
||||
"""
|
||||
Populate the `creator` field for existing Document records.
|
||||
|
||||
This function assigns the `creator` field using the existing
|
||||
DocumentAccess entries. We can be sure that all documents have at
|
||||
least one user with "owner" role. If the document has several roles,
|
||||
it should take the entry with the oldest date of creation.
|
||||
|
||||
The update is performed using efficient bulk queries with Django's
|
||||
Subquery and OuterRef to minimize database hits and ensure performance.
|
||||
|
||||
Note: After running this migration, we quickly modify the schema to make
|
||||
the `creator` field required.
|
||||
"""
|
||||
Document = apps.get_model("core", "Document")
|
||||
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||
|
||||
# Update `creator` using the "owner" role
|
||||
owner_subquery = DocumentAccess.objects.filter(
|
||||
document=OuterRef('pk'),
|
||||
user__isnull=False,
|
||||
role='owner',
|
||||
).order_by('created_at').values('user_id')[:1]
|
||||
|
||||
Document.objects.filter(
|
||||
creator__isnull=True
|
||||
).update(creator=Subquery(owner_subquery))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_field_creator_to_document'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
17
src/backend/core/migrations/0013_alter_user_options.py
Normal file
17
src/backend/core/migrations/0013_alter_user_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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
|
||||
@@ -130,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
regex=r"^[\w.@+-:]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -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 login to the admin site
|
||||
# stores the email used by staff users to log in to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
@@ -199,6 +199,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_user"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
@@ -239,6 +240,13 @@ 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):
|
||||
@@ -341,6 +349,13 @@ class Document(BaseModel):
|
||||
link_role = models.CharField(
|
||||
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,
|
||||
)
|
||||
|
||||
_content = None
|
||||
|
||||
@@ -508,64 +523,86 @@ 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": 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,
|
||||
"ai_transform": can_update,
|
||||
"ai_translate": can_update,
|
||||
"attachment_upload": can_update,
|
||||
"collaboration_auth": can_get,
|
||||
"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": is_owner_or_admin or is_editor,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"media_auth": can_get,
|
||||
"update": can_update,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_role,
|
||||
"versions_retrieve": has_role,
|
||||
}
|
||||
|
||||
def email_invitation(self, language, email, role, sender):
|
||||
"""Send email invitation."""
|
||||
|
||||
sender_name = sender.full_name or sender.email
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
"""Generate and send email from a template."""
|
||||
context = context or {}
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
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:
|
||||
send_mail(
|
||||
title,
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[email],
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", emails, exception)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", email, 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)
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
@@ -600,6 +637,37 @@ class LinkTrace(BaseModel):
|
||||
return f"{self.user!s} trace on document {self.document!s}"
|
||||
|
||||
|
||||
class DocumentFavorite(BaseModel):
|
||||
"""Relation model to store a user's favorite documents."""
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="favorited_by_users",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="favorite_documents"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document_favorite"
|
||||
verbose_name = _("Document favorite")
|
||||
verbose_name_plural = _("Document favorites")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "document"],
|
||||
name="unique_document_favorite_user",
|
||||
violation_error_message=_(
|
||||
"This document is already targeted by a favorite relation instance "
|
||||
"for the same user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} favorite on document {self.document!s}"
|
||||
|
||||
|
||||
class DocumentAccess(BaseAccess):
|
||||
"""Relation model to give access to a document for a user or a team with a role."""
|
||||
|
||||
@@ -628,7 +696,7 @@ class DocumentAccess(BaseAccess):
|
||||
violation_error_message=_("This team is already in this document."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
condition=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."),
|
||||
@@ -675,15 +743,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": is_owner_or_admin or is_editor,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
@@ -693,7 +761,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(
|
||||
@@ -712,7 +780,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"""
|
||||
@@ -730,7 +798,6 @@ 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:
|
||||
@@ -817,7 +884,7 @@ class TemplateAccess(BaseAccess):
|
||||
violation_error_message=_("This team is already in this template."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
condition=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."),
|
||||
@@ -850,6 +917,8 @@ class Invitation(BaseModel):
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -67,10 +67,19 @@ 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)
|
||||
|
||||
json_response = json.loads(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
|
||||
|
||||
if "answer" not in json_response:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
|
||||
43
src/backend/core/services/collaboration_services.py
Normal file
43
src/backend/core/services/collaboration_services.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""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}"
|
||||
)
|
||||
78
src/backend/core/services/converter_services.py
Normal file
78
src/backend/core/services/converter_services.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""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,6 +1,8 @@
|
||||
"""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
|
||||
@@ -128,11 +130,12 @@ def test_authentication_getter_existing_user_with_email(
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
def test_authentication_getter_existing_user_change_fields_sub(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change.
|
||||
It should update the email or name fields on the user when they change
|
||||
and the user was identified by its "sub".
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
@@ -162,6 +165,48 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
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.
|
||||
@@ -213,29 +258,6 @@ 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():
|
||||
@@ -341,7 +363,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not matches the sub but matches the email and is disabled,
|
||||
If an existing user does not match the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
@@ -365,3 +387,102 @@ 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,6 +11,9 @@ 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
|
||||
|
||||
@@ -316,7 +319,11 @@ 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):
|
||||
def test_api_document_accesses_update_administrator_except_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
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.
|
||||
@@ -351,18 +358,21 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
|
||||
|
||||
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
|
||||
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",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
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
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -420,7 +430,11 @@ 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):
|
||||
def test_api_document_accesses_update_administrator_to_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
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.
|
||||
@@ -457,16 +471,23 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
|
||||
|
||||
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:
|
||||
assert response.status_code == 200
|
||||
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
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -474,7 +495,11 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
def test_api_document_accesses_update_owner(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
A user who is an owner in a document should be allowed to update
|
||||
a user access for this document whatever the role.
|
||||
@@ -507,18 +532,24 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
|
||||
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:
|
||||
assert response.status_code == 200
|
||||
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
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
@@ -530,7 +561,11 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
|
||||
def test_api_document_accesses_update_owner_self(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
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.
|
||||
@@ -568,21 +603,23 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
|
||||
# Add another owner and it should now work
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
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
|
||||
@@ -656,10 +693,12 @@ 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
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a document should be allowed to delete an access
|
||||
Users who are administrators in a document should be allowed to delete access
|
||||
from the document provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -685,12 +724,13 @@ def test_api_document_accesses_delete_administrators_except_owners(
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
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}/",
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -729,7 +769,11 @@ 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):
|
||||
def test_api_document_accesses_delete_owners(
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Users should be able to delete the document access of another user
|
||||
for a document of which they are owner.
|
||||
@@ -753,12 +797,13 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
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}/",
|
||||
)
|
||||
|
||||
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,10 +171,11 @@ 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} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
f"{user.full_name} ({user.email}) invited you with the role "{role}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@@ -228,8 +229,9 @@ 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} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
f"{user.full_name} ({user.email}) invited you with the role "{role}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -339,6 +340,7 @@ 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",
|
||||
(
|
||||
@@ -402,10 +404,13 @@ 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} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
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
|
||||
else:
|
||||
assert models.Invitation.objects.exists() is False
|
||||
|
||||
@@ -452,10 +457,7 @@ 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: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert f"{user.full_name} a partagé un document avec vous!" in email_content
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_from_content_language_not_supported():
|
||||
@@ -494,10 +496,7 @@ 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: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_full_name_empty():
|
||||
@@ -535,10 +534,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: {document.title}" in email_content
|
||||
assert f"{user.email} shared a document with you!" in email_content
|
||||
assert (
|
||||
f'{user.email} invited you with the role "reader" on the '
|
||||
f"following document : {document.title}" in email_content
|
||||
f"{user.email.capitalize()} 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):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI transform
|
||||
Authenticated 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):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI translate
|
||||
Authenticated 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):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to upload a file
|
||||
Authenticated 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 exceeed the maximum size in settings."""
|
||||
"""The uploaded file should not exceed the maximum size in settings."""
|
||||
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
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"}
|
||||
308
src/backend/core/tests/documents/test_api_documents_favorite.py
Normal file
308
src/backend/core/tests/documents/test_api_documents_favorite.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Test favorite document API endpoint for users in impress's core app."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach",
|
||||
[
|
||||
"restricted",
|
||||
"authenticated",
|
||||
"public",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("method", ["post", "delete"])
|
||||
def test_api_document_favorite_anonymous_user(method, reach):
|
||||
"""Anonymous users should not be able to mark/unmark documents as favorites."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = getattr(APIClient(), method)(
|
||||
f"/api/v1.0/documents/{document.id!s}/favorite/"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Mark as favorite
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_forbidden():
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try marking as favorite
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""POST should not create duplicate favorites if already marked."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try to mark as favorite again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document already marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
|
||||
"""POST should not create duplicate favorites if already marked."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try to mark as favorite again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_delete_forbidden():
|
||||
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""DELETE should be idempotent if the document is not marked as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try to unmark as favorite when no favorite entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already not marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
|
||||
"""DELETE should be idempotent if the document is not marked as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try to unmark as favorite when no favorite entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""A user should be able to mark, unmark, and mark a document again as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
|
||||
|
||||
# Mark as favorite
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(url)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Mark as favorite again
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
|
||||
@@ -116,7 +119,10 @@ 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
|
||||
via,
|
||||
role,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
A user who is administrator or owner of a document should be allowed to update
|
||||
@@ -139,14 +145,16 @@ 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
|
||||
|
||||
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]
|
||||
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]
|
||||
|
||||
@@ -3,7 +3,9 @@ Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import operator
|
||||
import random
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -32,7 +34,47 @@ def test_api_documents_list_anonymous(reach, role):
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_direct():
|
||||
def test_api_documents_list_format():
|
||||
"""Validate the format of documents as returned by the list view."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_users = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
users=[user, *factories.UserFactory.create_batch(2)],
|
||||
favorited_by=[user, *other_users],
|
||||
link_traces=other_users,
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a direct
|
||||
owner/administrator/member of or documents that have a link reach other
|
||||
@@ -55,9 +97,8 @@ def test_api_documents_list_authenticated_direct():
|
||||
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -66,7 +107,9 @@ def test_api_documents_list_authenticated_direct():
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
def test_api_documents_list_authenticated_via_team(
|
||||
django_assert_num_queries, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a
|
||||
owner/administrator/member of via a team.
|
||||
@@ -89,7 +132,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -98,7 +142,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
def test_api_documents_list_authenticated_link_reach_restricted(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
An authenticated user who has link traces to a document that is restricted should not
|
||||
see it on the list view
|
||||
@@ -115,9 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -127,7 +174,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
assert results[0]["id"] == str(other_document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
|
||||
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
An authenticated user who has link traces to a document with public or authenticated
|
||||
link reach should see it on the list view.
|
||||
@@ -144,9 +193,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
|
||||
]
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -224,6 +274,143 @@ def test_api_documents_list_authenticated_distinct():
|
||||
assert content["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
|
||||
"""
|
||||
Ensure that marking documents as favorite does not generate additional queries
|
||||
when fetching the document list.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
url = "/api/v1.0/documents/"
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
assert all(result["is_favorite"] is False for result in results)
|
||||
|
||||
# Mark documents as favorite and check results again
|
||||
for document in special_documents:
|
||||
models.DocumentFavorite.objects.create(document=document, user=user)
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check if the "is_favorite" annotation is correctly set for the favorited documents
|
||||
favorited_ids = {str(doc.id) for doc in special_documents}
|
||||
for result in results:
|
||||
if result["id"] in favorited_ids:
|
||||
assert result["is_favorite"] is True
|
||||
else:
|
||||
assert result["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_and_access_rights():
|
||||
"""Filtering on querystring parameters should respect access rights."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
def random_favorited_by():
|
||||
return random.choice([[], [user], [other_user]])
|
||||
|
||||
# Documents that should be listed to this user
|
||||
listed_documents = [
|
||||
factories.DocumentFactory(
|
||||
link_reach="public",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
factories.DocumentFactory(
|
||||
link_reach="authenticated",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
users=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
]
|
||||
listed_ids = [str(doc.id) for doc in listed_documents]
|
||||
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
|
||||
|
||||
# Documents that should not be listed to this user
|
||||
factories.DocumentFactory(
|
||||
link_reach="public",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="authenticated",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
|
||||
filters = {
|
||||
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
|
||||
"title": random.choice([None, *word_list]),
|
||||
"favorite": random.choice([None, True, False]),
|
||||
"creator": random.choice([None, user, other_user]),
|
||||
"ordering": random.choice(
|
||||
[
|
||||
None,
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"is_favorite",
|
||||
"-is_favorite",
|
||||
"nb_accesses",
|
||||
"-nb_accesses",
|
||||
"title",
|
||||
"-title",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
]
|
||||
),
|
||||
}
|
||||
query_params = {key: value for key, value in filters.items() if value is not None}
|
||||
querystring = urlencode(query_params)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all documents in results respect expected access rights
|
||||
for result in results:
|
||||
assert result["id"] in listed_ids
|
||||
|
||||
|
||||
# Filters: ordering
|
||||
|
||||
|
||||
def test_api_documents_list_ordering_default():
|
||||
"""Documents should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
@@ -254,10 +441,14 @@ def test_api_documents_list_ordering_by_fields():
|
||||
for parameter in [
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
"is_favorite",
|
||||
"-is_favorite",
|
||||
"nb_accesses",
|
||||
"-nb_accesses",
|
||||
"title",
|
||||
"-title",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
]:
|
||||
is_descending = parameter.startswith("-")
|
||||
field = parameter.lstrip("-")
|
||||
@@ -272,3 +463,212 @@ def test_api_documents_list_ordering_by_fields():
|
||||
compare = operator.ge if is_descending else operator.le
|
||||
for i in range(4):
|
||||
assert compare(results[i][field], results[i + 1][field])
|
||||
|
||||
|
||||
# Filters: is_creator_me
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they created.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are created by the current user
|
||||
for result in results:
|
||||
assert result["creator"] == str(user.id)
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents created by others.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are created by other users
|
||||
for result in results:
|
||||
assert result["creator"] != str(user.id)
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_invalid():
|
||||
"""Filtering with an invalid `is_creator_me` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_favorite
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they marked as favorite.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are marked as favorite by the current user
|
||||
for result in results:
|
||||
assert result["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they didn't mark as favorite.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are not marked as favorite by the current user
|
||||
for result in results:
|
||||
assert result["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_invalid():
|
||||
"""Filtering with an invalid `is_favorite` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: link_reach
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_list_filter_link_reach(reach):
|
||||
"""Authenticated users should be able to filter documents by link reach."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(5, users=[user])
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all results have the chosen link reach
|
||||
for result in results:
|
||||
assert result["link_reach"] == reach
|
||||
|
||||
|
||||
def test_api_documents_list_filter_link_reach_invalid():
|
||||
"""Filtering with an invalid `link_reach` value should raise an error."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?link_reach=invalid")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"link_reach": [
|
||||
"Select a valid choice. invalid is not one of the available choices."
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 1), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 5), # Empty string
|
||||
],
|
||||
)
|
||||
def test_api_documents_list_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, users=[user])
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(f"/api/v1.0/documents/?title={query:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert query.lower().strip() in result["title"].lower()
|
||||
|
||||
@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_retrieve_auth_anonymous_public():
|
||||
def test_api_documents_media_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_retrieve_auth_anonymous_public():
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
|
||||
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
|
||||
def test_api_documents_media_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_retrieve_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/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
|
||||
def test_api_documents_media_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_retrieve_auth_authenticated_public_or_authenticated(reach
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_retrieve_auth_authenticated_restricted():
|
||||
def test_api_documents_media_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_retrieve_auth_authenticated_restricted():
|
||||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -158,9 +158,9 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
"""
|
||||
Users who have a specific access to a document, whatever the role, should be able to
|
||||
Users who have 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_retrieve_auth_related(via, mock_user_teams):
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -26,9 +26,13 @@ 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",
|
||||
@@ -36,12 +40,14 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -84,9 +90,12 @@ 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",
|
||||
"link_configuration": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"media_auth": True,
|
||||
"link_configuration": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
@@ -94,12 +103,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
assert (
|
||||
@@ -168,43 +179,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
||||
access1_user = serializers.UserSerializer(instance=user).data
|
||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
||||
serializers.UserSerializer(instance=user)
|
||||
serializers.UserSerializer(instance=access2.user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": access1_user,
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": access2_user,
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 2,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -257,7 +251,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -268,81 +262,34 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -360,7 +307,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -371,98 +318,34 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -481,7 +364,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a restricted document to which
|
||||
they are related via a team whatever the role and see all its accesses.
|
||||
they are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -492,100 +375,33 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -132,7 +132,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
@@ -216,7 +223,14 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
@@ -255,7 +269,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
|
||||
@@ -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 an access
|
||||
Users who are administrators in a template should be allowed to delete access
|
||||
from the template provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
45
src/backend/core/tests/test_api_config.py
Normal file
45
src/backend/core/tests/test_api_config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Test config API endpoints in the Impress core app.
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
SENTRY_DSN="https://sentry.test/123",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config(is_authenticated):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
}
|
||||
@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_email_matching():
|
||||
"""While filtering by email, results should be filtered and sorted by similarity"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
|
||||
factories.UserFactory(email="jane.smith@example.gouv.fr")
|
||||
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
|
||||
factories.UserFactory(email="david.jones@example.gouv.fr")
|
||||
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
|
||||
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(alice.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_email_exclude_doc_user():
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
|
||||
@@ -32,15 +32,22 @@ def test_models_documents_id_unique():
|
||||
factories.DocumentFactory(id=document.id)
|
||||
|
||||
|
||||
def test_models_documents_creator_required():
|
||||
"""No field should be required on the Document model."""
|
||||
models.Document.objects.create()
|
||||
|
||||
|
||||
def test_models_documents_title_null():
|
||||
"""The "title" field can be null."""
|
||||
document = models.Document.objects.create(title=None)
|
||||
document = models.Document.objects.create(
|
||||
title=None, creator=factories.UserFactory()
|
||||
)
|
||||
assert document.title is None
|
||||
|
||||
|
||||
def test_models_documents_title_empty():
|
||||
"""The "title" field can be empty."""
|
||||
document = models.Document.objects.create(title="")
|
||||
document = models.Document.objects.create(title="", creator=factories.UserFactory())
|
||||
assert document.title == ""
|
||||
|
||||
|
||||
@@ -77,7 +84,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()
|
||||
@@ -88,9 +95,12 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"link_configuration": False,
|
||||
"collaboration_auth": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"link_configuration": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
@@ -111,7 +121,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()
|
||||
@@ -122,9 +132,12 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -145,7 +158,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()
|
||||
@@ -156,9 +169,12 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -179,9 +195,12 @@ def test_models_documents_get_abilities_owner():
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": True,
|
||||
"link_configuration": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -201,9 +220,12 @@ def test_models_documents_get_abilities_administrator():
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": True,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -226,9 +248,12 @@ 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,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -253,9 +278,12 @@ 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,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -281,9 +309,12 @@ 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,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -396,8 +427,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.email_invitation(
|
||||
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -410,15 +441,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()
|
||||
|
||||
@@ -428,11 +459,11 @@ def test_models_documents__email_invitation__success_fr():
|
||||
sender = factories.UserFactory(
|
||||
full_name="Test Sender2", email="sender2@example.com"
|
||||
)
|
||||
document.email_invitation(
|
||||
"fr-fr",
|
||||
document.send_invitation_email(
|
||||
"guest2@example.com",
|
||||
models.RoleChoices.OWNER,
|
||||
sender,
|
||||
"fr-fr",
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -445,8 +476,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
|
||||
|
||||
@@ -464,11 +495,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory()
|
||||
document.email_invitation(
|
||||
"en",
|
||||
document.send_invitation_email(
|
||||
"guest3@example.com",
|
||||
models.RoleChoices.ADMIN,
|
||||
sender,
|
||||
"en",
|
||||
)
|
||||
|
||||
# No email has been sent
|
||||
@@ -480,9 +511,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
|
||||
(
|
||||
_,
|
||||
email,
|
||||
emails,
|
||||
exception,
|
||||
) = mock_logger.call_args.args
|
||||
|
||||
assert email == "guest3@example.com"
|
||||
assert emails == ["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, 6), (20, 6)])
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
|
||||
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,3 +102,24 @@ 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"}
|
||||
|
||||
185
src/backend/core/tests/test_services_collaboration_services.py
Normal file
185
src/backend/core/tests/test_services_collaboration_services.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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
|
||||
147
src/backend/core/tests/test_services_converter_services.py
Normal file
147
src/backend/core/tests/test_services_converter_services.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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)
|
||||
@@ -55,4 +55,5 @@ urlpatterns = [
|
||||
]
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
]
|
||||
|
||||
@@ -132,10 +132,13 @@ def create_demo(stdout):
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
users_ids = list(models.User.objects.values_list("id", flat=True))
|
||||
|
||||
with Timeit(stdout, "Creating documents"):
|
||||
for _ in range(defaults.NB_OBJECTS["docs"]):
|
||||
queue.push(
|
||||
models.Document(
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
@@ -147,7 +150,6 @@ def create_demo(stdout):
|
||||
|
||||
with Timeit(stdout, "Creating docs accesses"):
|
||||
docs_ids = list(models.Document.objects.values_list("id", flat=True))
|
||||
users_ids = list(models.User.objects.values_list("id", flat=True))
|
||||
for doc_id in docs_ids:
|
||||
for user_id in random.sample(
|
||||
users_ids,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,8 +10,9 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
|
||||
By release, we mean the release from the version.json file à la Mozilla [1]
|
||||
(if any). If this file has not been found, it defaults to "NA".
|
||||
|
||||
[1]
|
||||
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
||||
"""
|
||||
# Try to get the current release from the version.json file generated by the
|
||||
# CI during the Docker image build
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||
return json.load(version)["version"]
|
||||
except FileNotFoundError:
|
||||
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
return pyproject_data["project"]["version"]
|
||||
except (FileNotFoundError, KeyError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
@@ -56,7 +50,7 @@ class Base(Configuration):
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* DJANGO_SENTRY_DSN
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
@@ -71,6 +65,7 @@ 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"
|
||||
@@ -104,6 +99,9 @@ class Base(Configuration):
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
@@ -237,6 +235,7 @@ class Base(Configuration):
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -353,9 +352,11 @@ 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)
|
||||
@@ -371,7 +372,28 @@ class Base(Configuration):
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||
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
|
||||
)
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
|
||||
# Crisp
|
||||
CRISP_WEBSITE_ID = values.Value(
|
||||
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -452,9 +474,25 @@ 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)
|
||||
@@ -470,17 +508,74 @@ class Base(Configuration):
|
||||
"day": 200,
|
||||
}
|
||||
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
# Y provider microservice
|
||||
Y_PROVIDER_API_KEY = values.Value(
|
||||
environ_name="Y_PROVIDER_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
|
||||
default="first_name",
|
||||
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
|
||||
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",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": values.Value(
|
||||
"ERROR",
|
||||
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -527,8 +622,9 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
@@ -576,23 +672,6 @@ class Development(Base):
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
LOGGING = values.DictValue(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"impress": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
@@ -623,7 +702,13 @@ class Production(Base):
|
||||
"""
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue(None)
|
||||
# Add allowed host from environment variables.
|
||||
# The machine hostname is added by default,
|
||||
# it makes the application pingable by a load balancer on the same machine by example
|
||||
ALLOWED_HOSTS = [
|
||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||
gethostbyname(gethostname()),
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
394
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
394
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,394 @@
|
||||
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"
|
||||
"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"
|
||||
"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
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: 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
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
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 "Lesen"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: core/models.py:87
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
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
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
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
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:364
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: core/models.py:365
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: core/models.py:368
|
||||
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:600
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:623
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
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
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:695
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: core/models.py:719
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: core/models.py:720
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/models.py:721
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: core/models.py:723
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: core/models.py:725
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: core/models.py:731
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
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/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: 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/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
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-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -29,15 +29,35 @@ msgstr ""
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:253
|
||||
#: 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
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:256
|
||||
#: core/api/serializers.py:417
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:262
|
||||
#: core/api/serializers.py:423
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -49,6 +69,10 @@ 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 ""
|
||||
@@ -65,224 +89,246 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:80
|
||||
#: core/models.py:83
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:101
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:109
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
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."
|
||||
#: core/models.py:135
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
#: core/models.py:143
|
||||
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
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:160
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:167
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:171
|
||||
#: core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:332 core/models.py:638
|
||||
#: core/models.py:342 core/models.py:718
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
#: core/models.py:364
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:348
|
||||
#: core/models.py:365
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:351
|
||||
#: core/models.py:368
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:530
|
||||
#, python-format
|
||||
msgid "%(sender_name)s shared a document with you: %(document)s"
|
||||
#: core/models.py:593
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:575
|
||||
#: core/models.py:624
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:630
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:602
|
||||
#: 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
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:603
|
||||
#: core/models.py:683
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
#: core/models.py:689
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
#: core/models.py:695
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621 core/models.py:810
|
||||
#: core/models.py:701 core/models.py:890
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:719
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:720
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:641
|
||||
#: core/models.py:721
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:643
|
||||
#: core/models.py:723
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
#: core/models.py:725
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
#: core/models.py:731
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:652
|
||||
#: core/models.py:732
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:871
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:872
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:878
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:884
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:827
|
||||
#: core/models.py:907
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:844
|
||||
#: core/models.py:926
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:845
|
||||
#: core/models.py:927
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:862
|
||||
#: core/models.py:944
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -308,36 +354,25 @@ 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:159
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: 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/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: 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:229
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
@@ -345,11 +380,15 @@ msgstr ""
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
#: impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
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-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -29,15 +29,35 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/serializers.py:253
|
||||
#: 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
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:256
|
||||
#: core/api/serializers.py:417
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:262
|
||||
#: core/api/serializers.py:423
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -49,6 +69,10 @@ 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"
|
||||
@@ -65,224 +89,246 @@ msgstr "Administrateur"
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:80
|
||||
#: core/models.py:83
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: core/models.py:84
|
||||
#: core/models.py:87
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:101
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:109
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:114
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
#: core/models.py:115
|
||||
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."
|
||||
#: core/models.py:135
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
#: core/models.py:143
|
||||
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
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:160
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:167
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:171
|
||||
#: core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:181
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:332 core/models.py:638
|
||||
#: core/models.py:342 core/models.py:718
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
#: core/models.py:364
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:348
|
||||
#: core/models.py:365
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:351
|
||||
#: core/models.py:368
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: 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:593
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:575
|
||||
#: core/models.py:624
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:630
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:602
|
||||
#: 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
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:603
|
||||
#: core/models.py:683
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
#: core/models.py:689
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
#: core/models.py:695
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621 core/models.py:810
|
||||
#: core/models.py:701 core/models.py:890
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:719
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:720
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:641
|
||||
#: core/models.py:721
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:643
|
||||
#: core/models.py:723
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
#: core/models.py:725
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
#: core/models.py:731
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:652
|
||||
#: core/models.py:732
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:871
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:872
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:878
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:884
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:827
|
||||
#: core/models.py:907
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:844
|
||||
#: core/models.py:926
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:845
|
||||
#: core/models.py:927
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:862
|
||||
#: core/models.py:944
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -308,48 +354,41 @@ 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:159
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: 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/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: 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:229
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Proposé par La Suite Numérique"
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " Proposé par %(brandname)s "
|
||||
|
||||
#: 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:176
|
||||
#: impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.7.0"
|
||||
version = "1.10.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -17,28 +17,29 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
description = "An application to print markdown to pdf from a set of managed templates."
|
||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3==1.35.44",
|
||||
"boto3==1.35.90",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.5.0",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-filter==24.3",
|
||||
"django-parler==2.3",
|
||||
"redis==5.1.1",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.2",
|
||||
"django==5.1.4",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
@@ -46,17 +47,17 @@ dependencies = [
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.52.0",
|
||||
"openai==1.58.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"PyJWT==2.10.1",
|
||||
"pypandoc==1.14",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.17.0",
|
||||
"sentry-sdk==2.19.2",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -69,20 +70,20 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.28.0",
|
||||
"pyfakefs==5.7.1",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.3",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pylint==3.3.3",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest==8.3.4",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.7.0",
|
||||
"ruff==0.8.4",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
@@ -127,6 +128,7 @@ select = [
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
|
||||
sections = { impress=["core"], django=["django"] }
|
||||
extra-standard-library = ["tomllib"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/*" = ["S", "SLF"]
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
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/
|
||||
@@ -40,7 +10,9 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
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
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps AS impress
|
||||
@@ -61,18 +33,9 @@ FROM impress AS impress-builder
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
ARG FRONTEND_THEME
|
||||
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
|
||||
|
||||
ARG Y_PROVIDER_URL
|
||||
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
|
||||
|
||||
ARG API_ORIGIN
|
||||
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
|
||||
ARG MEDIA_URL
|
||||
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
|
||||
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
|
||||
@@ -36,18 +36,25 @@ export const createDoc = async (
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('heading', { name: 'Untitled document' }).click();
|
||||
await page.keyboard.type(randomDocs[i]);
|
||||
await page.getByText('Created at ').click();
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await input.click();
|
||||
await input.fill(randomDocs[i]);
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
return randomDocs;
|
||||
};
|
||||
|
||||
export const verifyDocName = async (page: Page, docName: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveText(docName);
|
||||
};
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
@@ -60,7 +67,9 @@ export const addNewMember = async (
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
@@ -75,13 +84,9 @@ export const addNewMember = async (
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
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();
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
@@ -97,24 +102,22 @@ export const goToGridDoc = async (
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(docsGrid.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
const rows = docsGrid.getByRole('row');
|
||||
|
||||
const rows = datagridTable.getByRole('row');
|
||||
const row = title
|
||||
? rows.filter({
|
||||
hasText: title,
|
||||
})
|
||||
: rows.nth(nthRow);
|
||||
|
||||
const docTitleCell = row.getByRole('cell').nth(1);
|
||||
|
||||
const docTitle = await docTitleCell.textContent();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
|
||||
const docTitle = await docTitleContent.textContent();
|
||||
expect(docTitle).toBeDefined();
|
||||
|
||||
await row.getByRole('link').first().click();
|
||||
|
||||
162
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal file
162
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
const config = {
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
['de-de', 'German'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/config/') && response.status() === 200,
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
expect(await response.json()).toStrictEqual(config);
|
||||
});
|
||||
|
||||
test('it checks that sentry is trying to init from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
SENTRY_DSN: 'https://sentry.io/123',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
|
||||
const consoleMessage = page.waitForEvent('console', {
|
||||
timeout: 5000,
|
||||
predicate: (msg) => msg.text().includes(invalidMsg),
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
expect((await consoleMessage).text()).toContain(invalidMsg);
|
||||
});
|
||||
|
||||
test('it checks that theme is configured from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/config/') && response.status() === 200,
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const jsonResponse = await response.json();
|
||||
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
|
||||
|
||||
const footer = page.locator('footer').first();
|
||||
// alt 'Gouvernement Logo' comes from the theme
|
||||
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks that media server is configured from config endpoint', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
|
||||
await createDoc(page, 'doc-media', browserName, 1);
|
||||
|
||||
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();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
|
||||
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
// Check src of image
|
||||
expect(await image.getAttribute('src')).toMatch(
|
||||
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks that collaboration server is configured from config endpoint', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const randomDoc = await createDoc(
|
||||
page,
|
||||
'doc-collaboration',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
|
||||
});
|
||||
|
||||
test('it checks that Crisp is trying to init from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
CRISP_WEBSITE_ID: '1234',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(
|
||||
page.locator('#crisp-chatbox').getByText('Invalid website'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -18,14 +24,55 @@ test.describe('Doc Create', () => {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(datagridTable.getByText(docTitle)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -81,26 +86,67 @@ test.describe('Doc Editor', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the Doc is connected to the provider server', async ({
|
||||
/**
|
||||
* 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 ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:4444/');
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:4444/');
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
'ws://localhost:8083/collaboration/ws/?room=',
|
||||
);
|
||||
|
||||
const framesentPromise = webSocket.waitForEvent('framesent');
|
||||
// Is connected
|
||||
let framesentPromise = webSocket.waitForEvent('framesent');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
const framesent = await framesentPromise;
|
||||
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;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -110,7 +156,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -135,7 +181,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
// Check the first doc
|
||||
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
await verifyDocName(page, firstDoc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -144,7 +190,8 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
// Check the second doc
|
||||
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
|
||||
await verifyDocName(page, secondDoc);
|
||||
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc 2');
|
||||
@@ -154,9 +201,18 @@ test.describe('Doc Editor', () => {
|
||||
await goToGridDoc(page, {
|
||||
title: firstDoc,
|
||||
});
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
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 ({
|
||||
@@ -165,7 +221,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
// Check the first doc
|
||||
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
await verifyDocName(page, doc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -176,7 +232,7 @@ test.describe('Doc Editor', () => {
|
||||
nthRow: 2,
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
|
||||
await verifyDocName(page, secondDoc);
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: doc,
|
||||
@@ -191,7 +247,8 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
// Check the first doc
|
||||
const doc = await goToGridDoc(page);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
|
||||
await verifyDocName(page, doc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -224,13 +281,14 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document.'),
|
||||
).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it adds an image to the doc editor', async ({ page }) => {
|
||||
await goToGridDoc(page);
|
||||
test('it adds an image to the doc editor', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-image', browserName, 1);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
|
||||
@@ -3,13 +3,44 @@ import cs from 'convert-stream';
|
||||
import jsdom from 'jsdom';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Export', () => {
|
||||
test('it check if all elements are visible', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Download$/ })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Upload your docs to a Microsoft Word, Open Office or PDF document',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Template' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Close the modal' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
|
||||
});
|
||||
test('it converts the doc to pdf with a template integrated', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -20,15 +51,14 @@ test.describe('Doc Export', () => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export',
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -57,19 +87,19 @@ test.describe('Doc Export', () => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.docx`);
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export',
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByText('Docx').click();
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'Word / Open Office' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -97,7 +127,7 @@ test.describe('Doc Export', () => {
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
@@ -190,10 +220,9 @@ test.describe('Doc Export', () => {
|
||||
.click();
|
||||
|
||||
// Download
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export',
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,264 +1,14 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
type SmallDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Documents Grid', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const thead = datagrid.locator('thead');
|
||||
await expect(thead.getByText(/Document name/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Created at/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Updated at/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Your role/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Members/i)).toBeVisible();
|
||||
|
||||
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
|
||||
const docName = await row1.nth(1).textContent();
|
||||
expect(docName).toBeDefined();
|
||||
|
||||
const docCreatedAt = await row1.nth(2).textContent();
|
||||
expect(docCreatedAt).toBeDefined();
|
||||
|
||||
const docUpdatedAt = await row1.nth(3).textContent();
|
||||
expect(docUpdatedAt).toBeDefined();
|
||||
|
||||
const docRole = await row1.nth(4).textContent();
|
||||
expect(
|
||||
docRole &&
|
||||
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
|
||||
).toBeTruthy();
|
||||
|
||||
const docUserNumber = await row1.nth(5).textContent();
|
||||
expect(docUserNumber).toBeDefined();
|
||||
|
||||
// Open the document
|
||||
await row1.nth(1).click();
|
||||
|
||||
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
nameColumn: 'Document name',
|
||||
ordering: 'title',
|
||||
cellNumber: 1,
|
||||
orderDefault: '',
|
||||
orderDesc: '&ordering=-title',
|
||||
orderAsc: '&ordering=title',
|
||||
defaultColumn: false,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Created at',
|
||||
ordering: 'created_at',
|
||||
cellNumber: 2,
|
||||
orderDefault: '',
|
||||
orderDesc: '&ordering=-created_at',
|
||||
orderAsc: '&ordering=created_at',
|
||||
defaultColumn: false,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Updated at',
|
||||
ordering: 'updated_at',
|
||||
cellNumber: 3,
|
||||
orderDefault: '&ordering=-updated_at',
|
||||
orderDesc: '&ordering=updated_at',
|
||||
orderAsc: '',
|
||||
defaultColumn: true,
|
||||
},
|
||||
].forEach(
|
||||
({
|
||||
nameColumn,
|
||||
ordering,
|
||||
cellNumber,
|
||||
orderDefault,
|
||||
orderDesc,
|
||||
orderAsc,
|
||||
defaultColumn,
|
||||
}) => {
|
||||
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1${orderDefault}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromiseOrderingDesc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1${orderDesc}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromiseOrderingAsc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1${orderAsc}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
// Checks the initial state
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
const thead = datagridTable.locator('thead');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const docNameRow1 = datagridTable
|
||||
.getByRole('row')
|
||||
.nth(1)
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
const docNameRow2 = datagridTable
|
||||
.getByRole('row')
|
||||
.nth(2)
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Initial state
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const initialDocNameRow1 = await docNameRow1.textContent();
|
||||
const initialDocNameRow2 = await docNameRow2.textContent();
|
||||
|
||||
expect(initialDocNameRow1).toBeDefined();
|
||||
expect(initialDocNameRow2).toBeDefined();
|
||||
|
||||
// Ordering ASC
|
||||
await thead.getByText(nameColumn).click();
|
||||
|
||||
const responseOrderingAsc = await responsePromiseOrderingAsc;
|
||||
expect(responseOrderingAsc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const textDocNameRow1Asc = await docNameRow1.textContent();
|
||||
const textDocNameRow2Asc = await docNameRow2.textContent();
|
||||
|
||||
const compare = (comp1: string, comp2: string) => {
|
||||
const comparisonResult = comp1.localeCompare(comp2, 'en', {
|
||||
caseFirst: 'false',
|
||||
ignorePunctuation: true,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
|
||||
};
|
||||
|
||||
expect(
|
||||
textDocNameRow1Asc &&
|
||||
textDocNameRow2Asc &&
|
||||
compare(textDocNameRow1Asc, textDocNameRow2Asc),
|
||||
).toBeTruthy();
|
||||
|
||||
// Ordering Desc
|
||||
await thead.getByText(nameColumn).click();
|
||||
|
||||
const responseOrderingDesc = await responsePromiseOrderingDesc;
|
||||
expect(responseOrderingDesc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const textDocNameRow1Desc = await docNameRow1.textContent();
|
||||
const textDocNameRow2Desc = await docNameRow2.textContent();
|
||||
|
||||
expect(
|
||||
textDocNameRow1Desc &&
|
||||
textDocNameRow2Desc &&
|
||||
compare(textDocNameRow2Desc, textDocNameRow1Desc),
|
||||
).toBeTruthy();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('checks the pagination', async ({ page }) => {
|
||||
const responsePromisePage1 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromisePage2 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=2`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const datagridPage1 = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const responsePage1 = await responsePromisePage1;
|
||||
expect(responsePage1.ok()).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
|
||||
).toHaveText(/.*/);
|
||||
|
||||
await page.getByLabel('Go to page 2').click();
|
||||
|
||||
const datagridPage2 = page
|
||||
.getByLabel('Datagrid of the documents page 2')
|
||||
.getByRole('table');
|
||||
|
||||
const responsePage2 = await responsePromisePage2;
|
||||
expect(responsePage2.ok()).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
|
||||
).toHaveText(/.*/);
|
||||
});
|
||||
|
||||
test('it deletes the document', async ({ page }) => {
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
|
||||
|
||||
const docName = await docRow.nth(1).textContent();
|
||||
|
||||
await docRow
|
||||
.getByRole('button', {
|
||||
name: 'Delete the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText(`Deleting the document "${docName}"`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Confirm deletion',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document has been deleted.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(datagrid.getByText(docName!)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Grid mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
@@ -326,19 +76,256 @@ test.describe('Documents Grid mobile', () => {
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const tableDatagrid = datagrid.getByRole('table');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const rows = tableDatagrid.getByRole('row');
|
||||
const rows = docsGrid.getByRole('row');
|
||||
const row = rows.filter({
|
||||
hasText: 'My mocked document',
|
||||
});
|
||||
|
||||
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
|
||||
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
|
||||
await expect(
|
||||
row.locator('[aria-describedby="doc-title"]').nth(0),
|
||||
).toHaveText('My mocked document');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document grid item options', () => {
|
||||
test('it deletes the document', async ({ page }) => {
|
||||
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 button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
|
||||
const removeButton = page.getByTestId(
|
||||
`docs-grid-actions-remove-${docs[0].id}`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await removeButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Confirm deletion',
|
||||
})
|
||||
.click();
|
||||
|
||||
const refetchResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const resultRefetch = await refetchResponse.json();
|
||||
expect(resultRefetch.count).toBe(result.count - 1);
|
||||
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document has been deleted.'),
|
||||
).toBeVisible();
|
||||
await expect(button).toBeHidden();
|
||||
});
|
||||
|
||||
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
{
|
||||
id: 'mocked-document-id',
|
||||
content: '',
|
||||
title: 'Mocked document',
|
||||
accesses: [],
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
link_configuration: false,
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'restricted',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const button = page.getByTestId(
|
||||
`docs-grid-actions-button-mocked-document-id`,
|
||||
);
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
const removeButton = page.getByTestId(
|
||||
`docs-grid-actions-remove-mocked-document-id`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await removeButton.isDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
// All Docs
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
const allCount = result.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
const allDocs = page.getByLabel('All docs');
|
||||
const myDocs = page.getByLabel('My docs');
|
||||
const sharedWithMe = page.getByLabel('Shared with me');
|
||||
|
||||
// Initial state
|
||||
await expect(allDocs).toBeVisible();
|
||||
await expect(allDocs).toHaveCSS('background-color', 'rgb(238, 238, 238)');
|
||||
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await expect(myDocs).toBeVisible();
|
||||
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await expect(sharedWithMe).toBeVisible();
|
||||
await expect(sharedWithMe).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await allDocs.click();
|
||||
|
||||
let url = new URL(page.url());
|
||||
let target = url.searchParams.get('target');
|
||||
expect(target).toBe('all_docs');
|
||||
|
||||
// My docs
|
||||
await myDocs.click();
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('my_docs');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
const responseMyDocs = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('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();
|
||||
expect(countMyDocs).toBeLessThanOrEqual(allCount);
|
||||
|
||||
// Shared with me
|
||||
await sharedWithMe.click();
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('shared_with_me');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
const responseSharedWithMe = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('documents/?page=1&is_creator_me=false') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const resultSharedWithMe = await responseSharedWithMe.json();
|
||||
const countSharedWithMe = resultSharedWithMe.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
|
||||
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Grid', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
let docs: SmallDoc[] = [];
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
|
||||
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[];
|
||||
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
|
||||
|
||||
const thead = page.getByTestId('docs-grid-header');
|
||||
await expect(thead.getByText(/Name/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Updated at/i)).toBeVisible();
|
||||
|
||||
await Promise.all(
|
||||
docs.map(async (doc) => {
|
||||
await expect(
|
||||
page.getByTestId(`docs-grid-name-${doc.id}`),
|
||||
).toBeVisible();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('checks the infinite scroll', async ({ page }) => {
|
||||
let docs: SmallDoc[] = [];
|
||||
const responsePromisePage1 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromisePage2 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith(`/documents/?page=2`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePage1 = await responsePromisePage1;
|
||||
expect(responsePage1.ok()).toBeTruthy();
|
||||
let result = await responsePage1.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
await Promise.all(
|
||||
docs.map(async (doc) => {
|
||||
await expect(
|
||||
page.getByTestId(`docs-grid-name-${doc.id}`),
|
||||
).toBeVisible();
|
||||
}),
|
||||
);
|
||||
|
||||
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
|
||||
const responsePage2 = await responsePromisePage2;
|
||||
result = await responsePage2.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
await Promise.all(
|
||||
docs.map(async (doc) => {
|
||||
await expect(
|
||||
page.getByTestId(`docs-grid-name-${doc.id}`),
|
||||
).toBeVisible();
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mockedAccesses,
|
||||
mockedDocument,
|
||||
mockedInvitations,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -59,84 +60,31 @@ test.describe('Doc Header', () => {
|
||||
const card = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
await expect(card.locator('a').getByText('home')).toBeVisible();
|
||||
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
await expect(card.getByText('Public')).toBeVisible();
|
||||
await expect(
|
||||
card.getByText('Created at 09/01/2021, 11:00 AM'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByText('Owners: Super Owner / super2@owner.com'),
|
||||
).toBeVisible();
|
||||
await expect(card.getByText('Your role: Owner')).toBeVisible();
|
||||
|
||||
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(docTitle).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Public document')).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Owner ·')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Open the document options' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the title doc', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
|
||||
|
||||
await page.getByRole('heading', { name: randomDoc }).fill(' ');
|
||||
await page.getByText('Created at').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Untitled document' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the title doc from editor heading', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
})
|
||||
.click();
|
||||
|
||||
const docHeader = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
|
||||
await expect(
|
||||
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('h1').click();
|
||||
await page.keyboard.type('Hello World', { delay: 100 });
|
||||
|
||||
await expect(
|
||||
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Document title updated successfully'),
|
||||
).toBeVisible();
|
||||
|
||||
await docHeader
|
||||
.getByRole('heading', { name: 'Hello World', level: 2 })
|
||||
.fill('Top World');
|
||||
|
||||
await editor.locator('h1').fill('Super World');
|
||||
|
||||
await expect(
|
||||
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
await editor.locator('h1').fill('');
|
||||
|
||||
await docHeader
|
||||
.getByRole('heading', { name: 'Top World', level: 2 })
|
||||
.fill(' ');
|
||||
|
||||
await page.getByText('Created at').click();
|
||||
|
||||
await expect(
|
||||
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
|
||||
).toBeVisible();
|
||||
await createDoc(page, 'doc-update', browserName, 1);
|
||||
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(docTitle).toBeVisible();
|
||||
await docTitle.fill('Hello World');
|
||||
await docTitle.blur();
|
||||
await verifyDocName(page, 'Hello World');
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -146,7 +94,13 @@ test.describe('Doc Header', () => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -159,9 +113,7 @@ test.describe('Doc Header', () => {
|
||||
page.getByText('The document has been deleted.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a new document' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
|
||||
|
||||
const row = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
@@ -195,16 +147,13 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -212,34 +161,40 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).not.toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
memberCard.getByText('test@accesses.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
@@ -273,16 +228,12 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -290,36 +241,24 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).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('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -351,16 +290,12 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).not.toHaveAttribute('contenteditable');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -368,36 +303,24 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).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('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -414,7 +337,7 @@ test.describe('Doc Header', () => {
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -449,7 +372,7 @@ test.describe('Doc Header', () => {
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -471,9 +394,7 @@ test.describe('Doc Header', () => {
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(
|
||||
`<h1 data-level="1">Hello World</h1><p></p>`,
|
||||
);
|
||||
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -501,6 +422,7 @@ test.describe('Documents Header mobile', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await expect(page.getByLabel('Share modal')).toBeVisible();
|
||||
|
||||
@@ -16,163 +16,82 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1
|
||||
// Select user 1 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()).results as {
|
||||
email: string;
|
||||
full_name: string;
|
||||
}[];
|
||||
|
||||
await page.getByRole('option', { name: users[0].email }).click();
|
||||
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();
|
||||
|
||||
// Select user 2
|
||||
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
|
||||
await inputSearch.fill('user');
|
||||
await page.getByRole('option', { name: users[1].email }).click();
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[1].email}`)
|
||||
.click();
|
||||
|
||||
// Select email
|
||||
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
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
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();
|
||||
await quickSearchContent.getByText(email).click();
|
||||
await expect(list.getByText(email)).toBeVisible();
|
||||
|
||||
// Check roles are displayed
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
|
||||
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
|
||||
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('option', { name: 'Administrator' }),
|
||||
page.getByRole('button', { name: 'Administrator' }),
|
||||
).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,
|
||||
);
|
||||
// Validate
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
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 invitation added
|
||||
await expect(
|
||||
quickSearchContent.getByText('Pending invitations'),
|
||||
).toBeVisible();
|
||||
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
|
||||
|
||||
// Check user added
|
||||
await expect(page.getByText('Share with 3 users')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
quickSearchContent.getByText(users[0].full_name).first(),
|
||||
).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(
|
||||
listInvitation.locator('li').getByText('Invited'),
|
||||
quickSearchContent.getByText(users[0].email).first(),
|
||||
).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(
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
quickSearchContent.getByText(users[1].email).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[1].full_name).first(),
|
||||
).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 ({
|
||||
@@ -183,40 +102,43 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const [email] = randomName('test@test.fr', browserName, 1);
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).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.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 400,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await expect(
|
||||
page.getByText(`"${email}" is already invited to the document.`),
|
||||
).toBeVisible();
|
||||
@@ -233,31 +155,32 @@ test.describe('Document create member', () => {
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('EN').click();
|
||||
await header.getByRole('option', { name: 'FR' }).click();
|
||||
await header.getByRole('option', { name: 'translate Français' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(
|
||||
/Trouver un membre à ajouter au document/,
|
||||
);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrateur' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Valider' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
|
||||
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
@@ -270,41 +193,46 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
const li = listInvitation.locator('li').filter({
|
||||
hasText: email,
|
||||
});
|
||||
await expect(li.getByText(email)).toBeVisible();
|
||||
const listInvitation = page.getByTestId('doc-share-quick-search');
|
||||
const userInvitation = listInvitation.getByTestId(
|
||||
`doc-share-invitation-row-${email}`,
|
||||
);
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
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();
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { waitForElementCount } from '../helpers';
|
||||
|
||||
import { addNewMember, createDoc, goToGridDoc } from './common';
|
||||
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -15,10 +13,11 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page');
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
|
||||
const accesses = {
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -47,26 +46,23 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await goToGridDoc(page);
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
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();
|
||||
await page.mouse.wheel(0, 10);
|
||||
const prefix = 'doc-share-member-row';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-members');
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
|
||||
|
||||
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();
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
|
||||
|
||||
await expect(loadMore).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks a big list of invitations', async ({ page }) => {
|
||||
@@ -75,10 +71,10 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page');
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
const accesses = {
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : null,
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -104,131 +100,128 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List invitation card').locator('ul');
|
||||
await expect(list.locator('li')).toHaveCount(20);
|
||||
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
|
||||
await page.mouse.wheel(0, 10);
|
||||
const prefix = 'doc-share-invitation';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-invitations');
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(
|
||||
page.getByText(`impress@impress.world-page-1-16`).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(await list.locator('li').count()).toBeGreaterThan(20);
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-1-16`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-2-15`),
|
||||
page.getByText(`impress@impress.world-page-2-16`).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(loadMore).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the role rules', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
|
||||
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
|
||||
|
||||
const soleOwner = list.getByText(
|
||||
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(
|
||||
`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(soleOwner).toBeVisible();
|
||||
await expect(newUser).toBeVisible();
|
||||
|
||||
const username = await addNewMember(page, 0, 'Owner');
|
||||
await currentUserRole.click();
|
||||
await expect(soloOwner).toBeHidden();
|
||||
await list.click();
|
||||
|
||||
await expect(list.getByText(username)).toBeVisible();
|
||||
|
||||
await expect(soleOwner).toBeHidden();
|
||||
|
||||
const otherOwner = list.getByText(
|
||||
const otherOwner = page.getByText(
|
||||
`You cannot update the role or remove other owner.`,
|
||||
);
|
||||
|
||||
await newUserRoles.click();
|
||||
await expect(otherOwner).toBeVisible();
|
||||
await list.click();
|
||||
|
||||
const SelectRoleCurrentUser = list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: `user@${browserName}.e2e`,
|
||||
})
|
||||
.getByRole('combobox', { name: 'Role' });
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
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');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the delete members', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
|
||||
const nameMyself = `user@${browserName}.e2e`;
|
||||
await expect(list.getByText(nameMyself)).toBeVisible();
|
||||
const emailMyself = `user@${browserName}.e2e`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfMoreActions = mySelf.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
|
||||
const userOwner = await addNewMember(page, 0, 'Owner');
|
||||
await expect(list.getByText(userOwner)).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 userReader = await addNewMember(page, 0, 'Reader');
|
||||
await expect(list.getByText(userReader)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share' }).first().click();
|
||||
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: userReader,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
const userReaderEmail = await addNewMember(page, 0, 'Reader');
|
||||
|
||||
await expect(list.getByText(userReader)).toBeHidden();
|
||||
const userReader = list.getByTestId(
|
||||
`doc-share-member-row-${userReaderEmail}`,
|
||||
);
|
||||
const userReaderMoreActions = userReader.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: nameMyself,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
await expect(mySelf).toBeVisible();
|
||||
await expect(userOwner).toBeVisible();
|
||||
await expect(userReader).toBeVisible();
|
||||
|
||||
await expect(list.getByText(nameMyself)).toBeHidden();
|
||||
await expect(userOwnerMoreActions).toBeVisible();
|
||||
await expect(userReaderMoreActions).toBeVisible();
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
|
||||
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('The member has been removed from the document').first(),
|
||||
page.getByText('You do not have permission to perform this action.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Share', level: 3 }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
|
||||
|
||||
test('Check the presence of the meta tag noindex', async ({ page }) => {
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
});
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
|
||||
await expect(page).toHaveURL('/');
|
||||
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
});
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
|
||||
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal file
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -17,123 +17,29 @@ test.describe('Doc Table Content', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Table of contents',
|
||||
})
|
||||
.click();
|
||||
await page.locator('.ProseMirror').click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
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('Hello World');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
await page.getByRole('button', { name: 'Strike' }).click();
|
||||
const summaryContainer = page.locator('#summaryContainer');
|
||||
await summaryContainer.click();
|
||||
|
||||
await page.locator('.bn-block-outer').first().click();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
const level1 = summaryContainer.getByText('Level 1');
|
||||
const level2 = summaryContainer.getByText('Level 2');
|
||||
const level3 = summaryContainer.getByText('Level 3');
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await expect(level1).toBeVisible();
|
||||
await expect(level1).toHaveCSS('padding', /4px 0px/);
|
||||
await expect(level1).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World', { delay: 100 });
|
||||
await expect(level2).toBeVisible();
|
||||
await expect(level2).toHaveCSS('padding-left', /14.4px/);
|
||||
await expect(level2).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Another World');
|
||||
|
||||
const hello = panel.getByText('Hello World');
|
||||
const superW = panel.getByText('Super World');
|
||||
const another = panel.getByText('Another World');
|
||||
|
||||
await expect(hello).toBeVisible();
|
||||
await expect(hello).toHaveCSS('font-size', /17/);
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await expect(superW).toBeVisible();
|
||||
await expect(superW).toHaveCSS('font-size', /14/);
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await expect(another).toBeVisible();
|
||||
await expect(another).toHaveCSS('font-size', /12/);
|
||||
await expect(another).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await hello.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await another.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'false');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await panel.getByText('Back to top').click();
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await panel.getByText('Go to bottom').click();
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-table-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await expect(page.getByLabel('Open the panel')).toBeHidden();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World', { delay: 100 });
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World', { delay: 100 });
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Close the panel')).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await expect(panel.getByText('Hello World')).toBeVisible();
|
||||
await expect(panel.getByText('Super World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Close the panel').click();
|
||||
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(level3).toBeVisible();
|
||||
await expect(level3).toHaveCSS('padding-left', /24px/);
|
||||
await expect(level3).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -10,7 +15,7 @@ test.describe('Doc Version', () => {
|
||||
test('it displays the doc versions', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -18,24 +23,29 @@ test.describe('Doc Version', () => {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(1);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await modal.getByRole('button', { name: 'close' }).click();
|
||||
await editor.click();
|
||||
await page.keyboard.type('# Hello World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Hello World' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror .bn-block')
|
||||
.getByText('Hello World')
|
||||
.getByRole('heading', { name: 'Hello World' })
|
||||
.fill('It will create a version');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
@@ -43,7 +53,9 @@ test.describe('Doc Version', () => {
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'It will create a version' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -52,19 +64,15 @@ test.describe('Doc Version', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(2);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeHidden();
|
||||
const items = await panel.locator('.version-item').all();
|
||||
expect(items.length).toBe(1);
|
||||
await items[0].click();
|
||||
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit document versions.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
await expect(page.getByText('It will create a version')).toBeHidden();
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
@@ -79,24 +87,17 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Version history' }),
|
||||
).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Table of content' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByLabel('Document panel').getByText('Versions'),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.locator('.bn-block-outer').last().fill('Hello');
|
||||
@@ -124,84 +125,26 @@ test.describe('Doc Version', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await panel.getByLabel('Open the version options').click();
|
||||
await page.getByText('Restore the version').click();
|
||||
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);
|
||||
await items[0].click();
|
||||
|
||||
await expect(page.getByText('Restore this version?')).toBeVisible();
|
||||
await expect(modal.getByText('World')).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore',
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Restore' }).click();
|
||||
await expect(page.getByText('Your current document will')).toBeVisible();
|
||||
await page.getByText('If a member is editing, his').click();
|
||||
|
||||
await expect(panel.locator('li')).toHaveCount(3);
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello')).toBeVisible();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it restores the doc version from button title', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.locator('.bn-block-outer').last().click();
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await editor.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await editor.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(editor.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore this version',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('Restore this version?')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.locator('li')).toHaveCount(3);
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, keyCloakSignIn } from './common';
|
||||
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
|
||||
|
||||
const browsersName = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
@@ -36,42 +36,38 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
});
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
|
||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Visibility: Restricted', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('A doc is not accessible when not authentified.', async ({
|
||||
test('A doc is not accessible when not authenticated.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -85,7 +81,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -102,7 +98,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('A doc is not accessible when authentified but not member.', async ({
|
||||
test('A doc is not accessible when authenticated but not member.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -111,7 +107,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -139,11 +135,13 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const otherBrowser = browsersName.find((b) => b !== browserName);
|
||||
const username = `user@${otherBrowser}.e2e`;
|
||||
@@ -151,14 +149,11 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await page.getByRole('option', { name: username }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
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('button', { name: 'Validate' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`User ${username} added to the document.`),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -176,8 +171,8 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,17 +193,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -217,20 +209,27 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Read only').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reading',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const cardContainer = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
|
||||
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Public', { exact: true }),
|
||||
cardContainer.getByText('Public document', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
@@ -247,9 +246,10 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
});
|
||||
|
||||
test('It checks a public doc in editable mode', async ({
|
||||
@@ -261,17 +261,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -280,20 +277,23 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const cardContainer = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
|
||||
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Public', { exact: true }),
|
||||
cardContainer.getByText('Public document', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
@@ -308,18 +308,15 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
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 unauthentified.', async ({
|
||||
test('A doc is not accessible when unauthenticated.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
@@ -328,22 +325,19 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'Authenticated unauthentified',
|
||||
'Authenticated unauthenticated',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -351,9 +345,7 @@ 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 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -385,17 +377,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -403,9 +392,7 @@ 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 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -422,19 +409,13 @@ 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();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
await expect(selectVisibility).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();
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeHidden();
|
||||
});
|
||||
|
||||
test('It checks a authenticated doc in editable mode', async ({
|
||||
@@ -451,17 +432,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -469,23 +447,15 @@ 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.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -498,20 +468,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeHidden();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
await expect(selectVisibility).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();
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,29 +75,13 @@ test.describe('Header mobile', () => {
|
||||
test('it checks the header when mobile', async ({ page }) => {
|
||||
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('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(page.getByText('English')).toBeHidden();
|
||||
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText('English')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Language', () => {
|
||||
test('checks the language picker', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Logout')).toBeVisible();
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
@@ -19,11 +15,15 @@ test.describe('Language', () => {
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
|
||||
|
||||
await header.getByRole('combobox').getByText('Français').click();
|
||||
await header.getByRole('option', { name: 'Deutsch' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Créer un nouveau document',
|
||||
}),
|
||||
header.getByRole('combobox').getByText('Deutsch'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Abmelden')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks that backend uses the same language as the frontend', async ({
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Left panel desktop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
|
||||
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Left panel mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
|
||||
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
|
||||
|
||||
const header = page.locator('header').first();
|
||||
const homeButton = page.getByRole('button', { name: 'house' });
|
||||
const newDocButton = page.getByRole('button', { name: 'New doc' });
|
||||
const languageButton = page.getByRole('combobox', { name: 'Language' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(homeButton).not.toBeInViewport();
|
||||
await expect(newDocButton).not.toBeInViewport();
|
||||
await expect(languageButton).not.toBeInViewport();
|
||||
await expect(logoutButton).not.toBeInViewport();
|
||||
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
|
||||
await expect(homeButton).toBeInViewport();
|
||||
await expect(newDocButton).toBeInViewport();
|
||||
await expect(languageButton).toBeInViewport();
|
||||
await expect(logoutButton).toBeInViewport();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.7.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -12,11 +12,13 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.48.1",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
"typescript": "*"
|
||||
"typescript": "*",
|
||||
"luxon": "3.5.0",
|
||||
"@types/luxon": "3.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 */
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=
|
||||
NEXT_PUBLIC_MEDIA_URL=
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
|
||||
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://test.jest
|
||||
NEXT_PUBLIC_THEME=test-theme
|
||||
|
||||
@@ -9,10 +9,6 @@ server {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
location ~ ^/docs/(.*)/versions/(.*)/$ {
|
||||
error_page 404 /docs/[id]/versions/[versionId]/;
|
||||
}
|
||||
|
||||
location /docs/ {
|
||||
error_page 404 /docs/[id]/;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user