mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 07:32:33 +02:00
Compare commits
230 Commits
production
...
compose-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed3ad6a81 | ||
|
|
350643a4c8 | ||
|
|
6f62d8ec2a | ||
|
|
24328b5d6b | ||
|
|
9179fdb2fa | ||
|
|
0d7d42254b | ||
|
|
67dc7feb98 | ||
|
|
5b4b100e90 | ||
|
|
b8be010389 | ||
|
|
97cfa2c1ad | ||
|
|
c018c6fcf5 | ||
|
|
70048328d1 | ||
|
|
55ddfe9181 | ||
|
|
ee41d156c7 | ||
|
|
5be2bc7360 | ||
|
|
e46ba4f506 | ||
|
|
7c8b969fa9 | ||
|
|
95515fd460 | ||
|
|
ce6cfc22ef | ||
|
|
4b3b441fc3 | ||
|
|
9194bf5a90 | ||
|
|
dc63a5839e | ||
|
|
d406846986 | ||
|
|
e85b07021e | ||
|
|
282200ac3d | ||
|
|
de8dea20d5 | ||
|
|
342fc2ab59 | ||
|
|
b8132ef393 | ||
|
|
2ede746d8a | ||
|
|
5bd0764bdd | ||
|
|
610948cd16 | ||
|
|
96bb99d6ec | ||
|
|
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 | ||
|
|
cbb6fc740a | ||
|
|
31c3dd6119 | ||
|
|
15700ddd8d | ||
|
|
d8673a8cf7 | ||
|
|
a5af9f0776 | ||
|
|
d715e7b3b6 | ||
|
|
1da5a6a411 | ||
|
|
af5ffc22ac | ||
|
|
3434029654 | ||
|
|
6baa06bd3f | ||
|
|
8107d4f531 | ||
|
|
f8c8044605 | ||
|
|
a84f4de02c | ||
|
|
3c374e3cc7 | ||
|
|
ff364f8b3d | ||
|
|
c0cb12f002 | ||
|
|
0f0f812059 | ||
|
|
7fc59ed497 | ||
|
|
60120852f5 | ||
|
|
f2c389e2b3 | ||
|
|
305359ae15 | ||
|
|
e35671c450 |
76
.github/workflows/crowdin_download.yml
vendored
Normal file
76
.github/workflows/crowdin_download.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/impress/
|
||||
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n
|
||||
67
.github/workflows/crowdin_upload.yml
vendored
Normal file
67
.github/workflows/crowdin_upload.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-front
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
working-directory: src/backend
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
52
.github/workflows/deploy.yml
vendored
52
.github/workflows/deploy.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
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 }}
|
||||
-
|
||||
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 ''${ARGOCD_PRODUCTION_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_PRODUCTION_WEBHOOK_URL
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
- notify-argocd
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.ref, 'refs/tags/preprod')
|
||||
steps:
|
||||
-
|
||||
name: Debug
|
||||
run: |
|
||||
echo "Start test when preprod is ready"
|
||||
93
.github/workflows/docker-hub.yml
vendored
93
.github/workflows/docker-hub.yml
vendored
@@ -19,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
|
||||
@@ -48,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
|
||||
@@ -69,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
|
||||
@@ -98,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
|
||||
@@ -120,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
|
||||
@@ -149,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' }}
|
||||
@@ -176,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 }}
|
||||
|
||||
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Install frontend installation reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '20.x'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
24
.github/workflows/helmfile-linter.yaml
vendored
24
.github/workflows/helmfile-linter.yaml
vendored
@@ -2,6 +2,7 @@ name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
@@ -12,11 +13,18 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "impress,secrets"
|
||||
-
|
||||
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
|
||||
|
||||
99
.github/workflows/impress-frontend.yml
vendored
99
.github/workflows/impress-frontend.yml
vendored
@@ -9,9 +9,15 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
test-front:
|
||||
needs: install-front
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -19,42 +25,17 @@ 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
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
run: cd src/frontend/ && yarn test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,43 +44,75 @@ 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
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- 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
|
||||
@@ -114,26 +127,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Install frontend dependencies
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
|
||||
- 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
|
||||
|
||||
11
.github/workflows/impress.yml
vendored
11
.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,15 +201,16 @@ 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]
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
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 }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,6 +30,7 @@ MANIFEST
|
||||
.next/
|
||||
|
||||
# Translations # Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
@@ -40,6 +41,7 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
env.d/production/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
175
CHANGELOG.md
175
CHANGELOG.md
@@ -9,6 +9,167 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- github actions to managed Crowdin workflow
|
||||
- 📈Integrate Posthog #540
|
||||
- 🏷️(backend) add content-type to uploaded files #552
|
||||
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) add abilities on doc row #581
|
||||
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
|
||||
## Fixed
|
||||
|
||||
-🐛(frontend) share modal is shown when you don't have the abilities #557
|
||||
-🐛(frontend) title copy break app #564
|
||||
|
||||
## [2.0.0] - 2025-01-13
|
||||
|
||||
## 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 #448
|
||||
- 💄(frontend) update doc versioning ui #463
|
||||
- 💄(frontend) update doc summary ui #473
|
||||
- 📝(doc) update readme.md to match V2 changes #558
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
|
||||
- 🐛(frontend) hide search and create doc button if not authenticated #555
|
||||
- 🐛(backend) race condition creation issue #556
|
||||
|
||||
## [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
|
||||
|
||||
- 📝Contributing.md #352
|
||||
- 🌐(frontend) add localization to editor #368
|
||||
- ✨Public and restricted doc editable #357
|
||||
- ✨(frontend) Add full name if available #380
|
||||
- ✨(backend) Add view accesses ability #376
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) list accesses if user has abilities #376
|
||||
- ♻️(frontend) avoid documents indexing in search engine #372
|
||||
- 👔(backend) doc restricted by default #388
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) require right to manage document accesses to see invitations #369
|
||||
- 🐛(i18n) same frontend and backend language using shared cookies #365
|
||||
- 🐛(frontend) add default toolbar buttons #355
|
||||
- 🐛(frontend) throttle error correctly display #378
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
|
||||
@@ -16,11 +177,11 @@ and this project adheres to
|
||||
|
||||
- ✨AI to doc editor #250
|
||||
- ✨(backend) allow uploading more types of attachments #309
|
||||
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #300
|
||||
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) More multi theme friendly #325
|
||||
- ♻️(frontend) more multi theme friendly #325
|
||||
- ♻️ Bootstrap frontend #257
|
||||
- ♻️ Add username in email #314
|
||||
|
||||
@@ -211,7 +372,15 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.6.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||
[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
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
|
||||
79
CONTRIBUTING.md
Normal file
79
CONTRIBUTING.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
## Selecting an issue
|
||||
|
||||
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
|
||||
|
||||
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
@@ -51,7 +51,7 @@ COPY ./src/backend /app/
|
||||
WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
@@ -76,6 +76,8 @@ RUN apk add \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
@@ -92,6 +94,11 @@ COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
|
||||
53
Makefile
53
Makefile
@@ -38,13 +38,13 @@ DB_PORT = 5432
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) ./bin/compose
|
||||
COMPOSE_PRODUCTION = DOCKER_USER=$(DOCKER_USER) COMPOSE_FILE=compose.production.yaml ./bin/compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -65,6 +65,19 @@ data/media:
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
# -- production volumes
|
||||
data/production/media:
|
||||
@mkdir -p data/production/media
|
||||
|
||||
data/production/certs:
|
||||
@mkdir -p data/production/certs
|
||||
|
||||
data/production/databases/backend:
|
||||
@mkdir -p data/production/databases/backend
|
||||
|
||||
data/production/databases/keycloak:
|
||||
@mkdir -p data/production/databases/keycloak
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
@@ -89,6 +102,27 @@ bootstrap: \
|
||||
mails-build
|
||||
.PHONY: bootstrap
|
||||
|
||||
bootstrap-production: ## Prepare project to run in production mode using docker compose
|
||||
bootstrap-production: \
|
||||
env.d/production \
|
||||
data/production/media \
|
||||
data/production/certs \
|
||||
data/production/databases/backend \
|
||||
data/production/databases/keycloak
|
||||
bootstrap-production:
|
||||
@echo 'Environment files created in env.d/production'
|
||||
@echo 'Edit them to set good value for your production environment'
|
||||
.PHONY: bootstrap-production
|
||||
|
||||
run-production: ## Run compose project in production mode
|
||||
@$(COMPOSE_PRODUCTION) up -d ingress
|
||||
.PHONY: run-production
|
||||
|
||||
stop-production: ## Stop compose project in production mode
|
||||
@$(COMPOSE_PRODUCTION) stop
|
||||
.PHONY: stop-production
|
||||
|
||||
|
||||
# -- Docker/compose
|
||||
build: cache ?= --no-cache
|
||||
build: ## build the project containers
|
||||
@@ -122,10 +156,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
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
.PHONY: run
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@@ -188,14 +220,12 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
@@ -229,6 +259,8 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
# -- Environment variable files
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
@@ -238,6 +270,9 @@ env.d/development/postgresql:
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
env.d/production:
|
||||
cp -rnf env.d/production.dist env.d/production
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
@@ -314,6 +349,10 @@ frontend-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
|
||||
160
README.md
160
README.md
@@ -1,113 +1,171 @@
|
||||
# Impress
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/docs">
|
||||
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Impress is a web application for real-time collaborative text editing with user and role based access rights.
|
||||
Features include :
|
||||
- User authentication through OIDC
|
||||
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
|
||||
- Document export to pdf and docx from predefined templates
|
||||
- Granular document permissions
|
||||
- Public link sharing
|
||||
- Offline mode
|
||||
<p align="center">
|
||||
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
|
||||
</p>
|
||||
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
<p align="center">
|
||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||
Chat on Matrix
|
||||
</a> - <a href="/docs/">
|
||||
Documentation
|
||||
</a> - <a href="#getting-started">
|
||||
Getting started
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Getting started
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
### Prerequisite
|
||||
## Why use Docs ❓
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
### Write
|
||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry 😅).
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
|
||||
```bash
|
||||
### Collaborate
|
||||
* 🤝 Collaborate in realtime with your team mates
|
||||
* 🔒 Granular access control to keep your information secure and shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
## Getting started 🔧
|
||||
### Test it
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
### Run it locally
|
||||
**Prerequisite**
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```shellscript
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
Docker version 27.4.1, build b9d17ea
|
||||
|
||||
$ docker compose -v
|
||||
docker compose version 1.27.4, build 40524192
|
||||
$ docker compose version
|
||||
Docker Compose version v2.32.1
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Project bootstrap
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by assigning your user to the `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-releated or migration-releated issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
|
||||
|
||||
command each time you are pulling code from the project repository to avoid dependency-releated or migration-releated issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
You can access to the project by going to http://localhost:3000.
|
||||
You can access to the project by going to <http://localhost:3000>.
|
||||
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
|
||||
```shellscript
|
||||
username: impress
|
||||
|
||||
password: impress
|
||||
```
|
||||
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run-with-frontend
|
||||
```
|
||||
|
||||
---
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make frontend-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Adding content
|
||||
|
||||
**Adding content**
|
||||
You can create a basic demo site by running:
|
||||
|
||||
$ make demo
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make help
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
**Django admin**
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
## Contributing
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
## Roadmap
|
||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||
|
||||
## License
|
||||
## Licence 📝
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions.
|
||||
|
||||
If you intend to make pull requests see CONTRIBUTING for guidelines.
|
||||
|
||||
Directory structure:
|
||||
|
||||
```markdown
|
||||
docs
|
||||
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
|
||||
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
|
||||
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
|
||||
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
|
||||
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
|
||||
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
|
||||
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
|
||||
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
|
||||
```
|
||||
|
||||
## Credits ❤️
|
||||
### Stack
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
|
||||
### States ❤️ open source
|
||||
Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs.
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -6,9 +6,9 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="impress"
|
||||
|
||||
if [ -z ${COMPOSE_FILE+x} ]; then
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
|
||||
fi
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
#
|
||||
@@ -40,9 +40,8 @@ function _set_user() {
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
17
bin/update_app_cacert.sh
Executable file
17
bin/update_app_cacert.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
|
||||
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
|
||||
# The script is executed with the following command:
|
||||
# $ bin/update_app_cacert.sh docs-production-backend-1
|
||||
|
||||
CONTAINER_NAME=${1:-"docs-production-backend-1"}
|
||||
|
||||
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
|
||||
|
||||
|
||||
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
|
||||
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
|
||||
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
|
||||
echo "end patching cacert.pem in ${CONTAINER_NAME}"
|
||||
167
compose.production.yaml
Normal file
167
compose.production.yaml
Normal file
@@ -0,0 +1,167 @@
|
||||
name: docs-production
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-q", "-U", "docs", "-d", "docs"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/production/postgresql
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/production/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
backend-migration:
|
||||
image: lasuite/impress-backend:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
command: ["python", "manage.py", "migrate", "--noinput"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Production
|
||||
env_file:
|
||||
- env.d/production/backend
|
||||
- env.d/production/postgresql
|
||||
- env.d/production/yprovider
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
backend:
|
||||
image: lasuite/impress-backend:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
restart: always
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Production
|
||||
env_file:
|
||||
- env.d/production/backend
|
||||
- env.d/production/postgresql
|
||||
- env.d/production/yprovider
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "manage.py", "check"]
|
||||
interval: 15s
|
||||
timeout: 30s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
backend-migration:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
minio-bootstrap:
|
||||
condition: service_completed_successfully
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: lasuite/impress-backend:latest
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Production
|
||||
env_file:
|
||||
- env.d/production/backend
|
||||
- env.d/production/postgresql
|
||||
- env.d/production/yprovider
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
frontend:
|
||||
image: lasuite/impress-frontend:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
y-provider:
|
||||
image: lasuite/impress-y-provider:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
env_file:
|
||||
- env.d/production/yprovider
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-q", "-U", "keycloak", "-d", "keycloak"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/production/kc_postgresql
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/production/databases/keycloak:/var/lib/postgresql/data/pgdata
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1.0
|
||||
command: ["start"]
|
||||
env_file:
|
||||
- env.d/production/keycloak
|
||||
- env.d/production/kc_postgresql
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ${DOCS_PROD_KEYCLOAK_CERT_FOLDER:-./data/production/certs}:/etc/ssl/certs:ro
|
||||
depends_on:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
minio-bootstrap:
|
||||
image: minio/mc
|
||||
env_file:
|
||||
- env.d/production/minio
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set docs http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && \
|
||||
/usr/bin/mc mb --ignore-existing docs/docs-media-storage && \
|
||||
/usr/bin/mc version enable docs/docs-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
env_file:
|
||||
- env.d/production/minio
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server /data
|
||||
volumes:
|
||||
- ./data/production/media:/data
|
||||
|
||||
ingress:
|
||||
image: nginx:1.27
|
||||
ports:
|
||||
- "${DOCS_PROD_NGING_PORT:-443}:8083"
|
||||
volumes:
|
||||
- ./docker/files/production/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ${DOCS_PROD_NGINX_CERT_FOLDER:-./data/production/certs}:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_started
|
||||
y-provider:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
@@ -1,6 +1,13 @@
|
||||
name: docs
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
@@ -23,6 +30,11 @@ services:
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
@@ -31,7 +43,9 @@ services:
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||
@@ -59,10 +73,15 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -93,9 +112,13 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -118,6 +141,7 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
@@ -134,9 +158,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
@@ -150,7 +171,7 @@ services:
|
||||
image: node:18
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
@@ -158,18 +179,21 @@ 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
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
@@ -201,4 +225,6 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
@@ -1,7 +1,7 @@
|
||||
#
|
||||
# Your crowdin's credentials
|
||||
#
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
base_path_env: CROWDIN_BASE_PATH
|
||||
|
||||
@@ -15,11 +15,11 @@ preserve_hierarchy: true
|
||||
# Files configuration
|
||||
#
|
||||
files: [
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-impress.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-impress.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
||||
dest: "/frontend-impress.json",
|
||||
|
||||
@@ -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;
|
||||
|
||||
132
docker/files/production/etc/nginx/conf.d/default.conf
Normal file
132
docker/files/production/etc/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,132 @@
|
||||
upstream docs_backend {
|
||||
server backend:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
upstream docs_frontend {
|
||||
server frontend:8080 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8083 ssl;
|
||||
server_name localhost;
|
||||
|
||||
# Disables server version feedback on pages and in headers
|
||||
server_tokens off;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
location @proxy_to_docs_backend {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://docs_backend;
|
||||
}
|
||||
|
||||
location @proxy_to_docs_frontend {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://docs_frontend;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri @proxy_to_docs_frontend;
|
||||
}
|
||||
|
||||
location /api {
|
||||
try_files $uri @proxy_to_docs_backend;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
try_files $uri @proxy_to_docs_backend;
|
||||
}
|
||||
|
||||
# Proxy auth for collaboration server
|
||||
location /collaboration/ws/ {
|
||||
# Collaboration Auth request configuration
|
||||
auth_request /collaboration-auth;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
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 X-Forwarded-Proto https;
|
||||
proxy_set_header Origin $http_origin;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /collaboration-auth {
|
||||
proxy_pass http://docs_backend/api/v1.0/documents/collaboration-auth/;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location /collaboration/api/ {
|
||||
# Collaboration server
|
||||
proxy_pass http://y-provider:4444;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /media-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/docs-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
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;
|
||||
}
|
||||
}
|
||||
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 MiB |
BIN
docs/assets/logo-docs.png
Normal file
BIN
docs/assets/logo-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
163
docs/examples/impress.values.yaml
Normal file
163
docs/examples/impress.values.yaml
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.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
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
|
||||
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
|
||||
|
||||
66
docs/installation/compose.md
Normal file
66
docs/installation/compose.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a configuration for running Docs in production using docker compose. This configuration is experimental, the official way to deploy Docs in production is to use [k8s](docs/installation/k8s.md)
|
||||
|
||||
## Requirements
|
||||
|
||||
- A modern version of Docker and its Compose plugin.
|
||||
- SSL certificates for Docs domain and Keycloak.
|
||||
- Two domain name. One for the Docs application and an other one for Keycloak. Both can be a subdomain of a common domain. (example: docs.domain.tld and keycloak.domain.tld)
|
||||
|
||||
## Installation
|
||||
|
||||
- Clone this repository: `git clone https://github.com/suitenumerique/docs.git`
|
||||
- Then in the clone directory you can run the following command: `make bootsrap-production`
|
||||
|
||||
## Configure your ssl certificates
|
||||
|
||||
You have to provide the ssl certificates. The easiest way is to use [certbot](https://certbot.eff.org/), generate the certificates with it (both for Docs and Keycloak) and then mount them in ingress and keycloak containers. Two environment variables can be used for that:
|
||||
- `DOCS_PROD_NGINX_CERT_FOLDER` path to the folder containing the certificates for Docs. This folder will be mounted in `/etc/nginx/ssl` in the container. You have to adapt the certificates name in the file `docker/files/production/etc/nginx/conf.d/default.conf` accordingly with the certificates name you have (see `ssl_certificate` and `ssl_certificate_key` directives).
|
||||
- `DOCS_PROD_KEYCLOAK_CERT_FOLDER` path to the folder containing the certificates for Keycloak. This folder will be mounted in `/etc/ssl/certs` in the container. You have to adapt the certificates name in the configuration file in `env.d/production/keycloak` to add the correct path for environment variables `KC_HTTPS_CERTIFICATE_FILE` and `KC_HTTPS_CERTIFICATE_KEY_FILE`.
|
||||
|
||||
### Configuration
|
||||
|
||||
All the configuration files are in the directory `env.d/production`. You have to edit all the files to complete them. For the OIDC information you will have them once Keycloak will be running and you have configured your own realm on it.
|
||||
|
||||
#### env.d/production/minio
|
||||
|
||||
All the settings related to Minio. You have to set a username and a password to manage the minio cluster. You will need them later in the `env.d/production/backend` file.
|
||||
|
||||
#### env.d/production/postgresql
|
||||
|
||||
All the settings related to the Postgresql database used by the Django application.
|
||||
|
||||
#### env.d/production/yprovider
|
||||
|
||||
All the settings related to the collaboration server. All the secret and api key must be generated.
|
||||
|
||||
#### env.d/production/kc_postgresql
|
||||
|
||||
All the settings related to the Postgresql database used by keycloak.
|
||||
|
||||
#### env.d/production/keycloak
|
||||
|
||||
All the settings related to the Keycloak application.
|
||||
|
||||
#### env.d/production/backend
|
||||
|
||||
All the settings related to the Django application. Only the settings you don't have for now are all the one related to OIDC. You will have them once the compose started and you can access to Keycloak.
|
||||
|
||||
## Run the compose configuration
|
||||
|
||||
The compose configuration can be run with the following command: `make run-production`. The first start can be a little bit long, lots of things are created. Once started you can check that everything is running with the following command: `COMPOSE_FILE=compose.production.yaml ./bin/compose ps`
|
||||
|
||||
## Configure keycloak
|
||||
|
||||
You have to create a new realm in your Keycloak and once created you have to create a new OIDC client in it. You will use this client to configure the OIDC part in `env.d/production/backend`. This is the last missing part to complete the Django application configuration.
|
||||
Once the client information set in `env.d/production/backend` you have to start the containers again by running the commande `make run-production`. The command will recreate the containers with the good configuration.
|
||||
|
||||
### Helpers
|
||||
|
||||
there is a helper script to control the `docker compose` command. You can export the variable `COMPOSE_FILE` with the compose filename (`export COMPOSE_FILE=compose.production.yaml`). After you can run `./bin/compose` to run the docker compose command line.
|
||||
|
||||
Makefile commands available:
|
||||
- `make bootstrap-production`: create the configuration files in `env.d/production`, create the directories : `data/production`. Both directories must be backup, if you loose them you loose all the data related to the application.
|
||||
- `make run-production`: up the ingress containers. Will start all the containers needed in cascade.
|
||||
- `make stop-production`: stop all the containers.
|
||||
231
docs/installation/k8s.md
Normal file
231
docs/installation/k8s.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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
CROWDIN_API_TOKEN=Your-Api-Token
|
||||
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
|
||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||
CROWDIN_BASE_PATH=/app/src
|
||||
|
||||
58
env.d/production.dist/backend
Normal file
58
env.d/production.dist/backend
Normal file
@@ -0,0 +1,58 @@
|
||||
## Django
|
||||
DJANGO_ALLOWED_HOSTS=impress.127.0.0.1.nip.io,keycloack.127.0.0.1.nip.io
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=ThisIsAnExamplePassword
|
||||
|
||||
# Logging
|
||||
# Set to DEBUG level for dev only
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
|
||||
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="https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=<minio root user>
|
||||
AWS_S3_SECRET_ACCESS_KEY=<minio root password>
|
||||
AWS_STORAGE_BUCKET_NAME=docs-media-storage
|
||||
MEDIA_BASE_URL=impress.127.0.0.1.nip.io
|
||||
|
||||
# OIDC
|
||||
USER_OIDC_FIELD_TO_SHORTNAME="given_name"
|
||||
USER_OIDC_FIELDS_TO_FULLNAME="given_name,usual_name"
|
||||
OIDC_OP_JWKS_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRETThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
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
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["https://impress.127.0.0.1.nip.io"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# AI
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=dsfr
|
||||
9
env.d/production.dist/kc_postgresql
Normal file
9
env.d/production.dist/kc_postgresql
Normal file
@@ -0,0 +1,9 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=keycloak
|
||||
POSTGRES_PASSWORD=<Set postgresql password>
|
||||
|
||||
# Keycloak database configuration
|
||||
KC_DB_URL_DATABASE=keycloak
|
||||
KC_DB_USERNAME=keycloak
|
||||
KC_DB_PASSWORD=<Same password as above>
|
||||
9
env.d/production.dist/keycloak
Normal file
9
env.d/production.dist/keycloak
Normal file
@@ -0,0 +1,9 @@
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=<Change this admin user>
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=<Change this admin password>
|
||||
KC_DB=postgres
|
||||
KC_DB_URL_HOST=kc_postgresql
|
||||
KC_DB_SCHEMA=public
|
||||
PROXY_ADDRESS_FORWARDING='true'
|
||||
KC_HOSTNAME=http://localhost:8083
|
||||
KC_HTTPS_CERTIFICATE_FILE=/etc/ssl/certs/docs.crt
|
||||
KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/ssl/private/docs.key
|
||||
2
env.d/production.dist/minio
Normal file
2
env.d/production.dist/minio
Normal file
@@ -0,0 +1,2 @@
|
||||
MINIO_ROOT_USER=<Set minio root username>
|
||||
MINIO_ROOT_PASSWORD=<Set minio root password>
|
||||
11
env.d/production.dist/postgresql
Normal file
11
env.d/production.dist/postgresql
Normal file
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=docs
|
||||
POSTGRES_USER=docs
|
||||
POSTGRES_PASSWORD=<Set postgresql password>
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=docs
|
||||
DB_USER=docs
|
||||
DB_PASSWORD=<Same password as above>
|
||||
DB_PORT=5432
|
||||
5
env.d/production.dist/yprovider
Normal file
5
env.d/production.dist/yprovider
Normal file
@@ -0,0 +1,5 @@
|
||||
COLLABORATION_LOGGING=true
|
||||
Y_PROVIDER_API_KEY=<Set y provider api key>
|
||||
COLLABORATION_API_URL=https://impress.127.0.0.1.nip.io/collaboration/api/
|
||||
COLLABORATION_SERVER_ORIGIN=https://impress.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_SECRET=<Set collaboration secret>
|
||||
@@ -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)
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Permission handlers for the impress core app."""
|
||||
|
||||
from django.core import exceptions
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.models import DocumentAccess, RoleChoices
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
@@ -59,6 +62,38 @@ class IsOwnedOrPublic(IsAuthenticated):
|
||||
return False
|
||||
|
||||
|
||||
class CanCreateInvitationPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class to handle permission checks for managing invitations.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
|
||||
# Ensure the user is authenticated
|
||||
if not (bool(request.auth) or request.user.is_authenticated):
|
||||
return False
|
||||
|
||||
# Apply permission checks only for creation (POST requests)
|
||||
if view.action != "create":
|
||||
return True
|
||||
|
||||
# Check if resource_id is passed in the context
|
||||
try:
|
||||
document_id = view.kwargs["resource_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a document ID in kwargs to manage document invitations."
|
||||
) from exc
|
||||
|
||||
# Check if the user has access to manage invitations (Owner/Admin roles)
|
||||
return DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document=document_id,
|
||||
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
|
||||
).exists()
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
|
||||
@@ -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_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -190,6 +232,104 @@ 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 on its sub (unique identifier). Default on email if allowed in settings
|
||||
email = validated_data["email"]
|
||||
|
||||
try:
|
||||
user = models.User.objects.get_user_by_sub_or_email(
|
||||
validated_data["sub"], email
|
||||
)
|
||||
except models.DuplicateEmailError as err:
|
||||
raise serializers.ValidationError({"email": [err.message]}) from err
|
||||
|
||||
if user:
|
||||
email = user.email
|
||||
language = user.language or language
|
||||
|
||||
try:
|
||||
document_content = YdocConverter().convert_markdown(
|
||||
validated_data["content"]
|
||||
)
|
||||
except ConversionError as err:
|
||||
raise serializers.ValidationError(
|
||||
{"content": ["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,
|
||||
)
|
||||
|
||||
self._send_email_notification(document, validated_data, email, language)
|
||||
return document
|
||||
|
||||
def _send_email_notification(self, document, validated_data, email, language):
|
||||
"""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)
|
||||
|
||||
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.
|
||||
@@ -248,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError("Could not determine file extension.")
|
||||
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = magic_mime_type
|
||||
|
||||
return file
|
||||
|
||||
@@ -255,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
"""Override validate to add the computed extension to validated_data."""
|
||||
attrs["expected_extension"] = self.context["expected_extension"]
|
||||
attrs["is_unsafe"] = self.context["is_unsafe"]
|
||||
attrs["content_type"] = self.context["content_type"]
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -328,48 +470,36 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
return {}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate and restrict invitation to new user based on email."""
|
||||
|
||||
"""Validate invitation data."""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
try:
|
||||
document_id = self.context["resource_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a document ID in kwargs to create a new document invitation."
|
||||
) from exc
|
||||
attrs["document_id"] = self.context["resource_id"]
|
||||
|
||||
if not user and user.is_authenticated:
|
||||
raise exceptions.PermissionDenied(
|
||||
"Anonymous users are not allowed to create invitations."
|
||||
)
|
||||
# Only set the issuer if the instance is being created
|
||||
if self.instance is None:
|
||||
attrs["issuer"] = user
|
||||
|
||||
if not models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document=document_id,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage invitations for this document."
|
||||
)
|
||||
return attrs
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not models.DocumentAccess.objects.filter(
|
||||
def validate_role(self, role):
|
||||
"""Custom validation for the role field."""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
document_id = self.context["resource_id"]
|
||||
|
||||
# If the role is OWNER, check if the user has OWNER access
|
||||
if role == models.RoleChoices.OWNER:
|
||||
if not models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document=document_id,
|
||||
role=models.RoleChoices.OWNER,
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a document can invite other users as owners."
|
||||
)
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Only owners of a document can invite other users as owners."
|
||||
)
|
||||
|
||||
attrs["document_id"] = document_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
return role
|
||||
|
||||
|
||||
class VersionFilterSerializer(serializers.Serializer):
|
||||
|
||||
@@ -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,7 +133,7 @@ class Pagination(pagination.PageNumberPagination):
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
@@ -156,11 +157,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 +186,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 +221,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 +235,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 +256,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 +279,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 +302,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 +397,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 +420,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 +463,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 +474,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 +490,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 +513,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 +521,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 +533,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 +546,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
|
||||
@@ -496,7 +605,10 @@ class DocumentViewSet(
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {"Metadata": {"owner": str(request.user.id)}}
|
||||
extra_args = {
|
||||
"Metadata": {"owner": str(request.user.id)},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
|
||||
@@ -505,15 +617,15 @@ 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
|
||||
@@ -525,33 +637,108 @@ class DocumentViewSet(
|
||||
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 = models.Document.objects.get(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 +764,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 +793,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.
|
||||
@@ -654,42 +841,83 @@ class DocumentAccessViewSet(
|
||||
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 an 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 an 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 +928,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 +962,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 +976,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 +1016,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.
|
||||
|
||||
@@ -807,7 +1044,10 @@ class InvitationViewset(
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
permission_classes = [
|
||||
permissions.CanCreateInvitationPermission,
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
queryset = (
|
||||
models.Invitation.objects.all()
|
||||
.select_related("document")
|
||||
@@ -833,7 +1073,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")
|
||||
@@ -842,14 +1082,20 @@ class InvitationViewset(
|
||||
)
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a document to see its accesses
|
||||
# The logged-in user should be administrator or owner to see its accesses
|
||||
queryset.filter(
|
||||
Q(document__accesses__user=user)
|
||||
| Q(document__accesses__team__in=teams),
|
||||
db.Q(
|
||||
document__accesses__user=user,
|
||||
document__accesses__role__in=models.PRIVILEGED_ROLES,
|
||||
)
|
||||
| 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
|
||||
@@ -860,6 +1106,35 @@ class InvitationViewset(
|
||||
|
||||
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",
|
||||
"POSTHOG_KEY",
|
||||
"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 _
|
||||
@@ -9,7 +11,9 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.models import DuplicateEmailError, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -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,13 +98,10 @@ 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)
|
||||
try:
|
||||
user = User.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
@@ -100,18 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
@@ -119,4 +127,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)
|
||||
|
||||
@@ -56,6 +56,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 +81,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."""
|
||||
|
||||
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Management command updating the metadata for all the files in the MinIO bucket."""
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import magic
|
||||
|
||||
from core.models import Document
|
||||
|
||||
# pylint: disable=too-many-locals, broad-exception-caught
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Update the metadata for all the files in the MinIO bucket."""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute management command."""
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
|
||||
documents = Document.objects.all()
|
||||
self.stdout.write(
|
||||
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
|
||||
)
|
||||
|
||||
for doc in documents:
|
||||
doc_id_str = str(doc.id)
|
||||
prefix = f"{doc_id_str}/attachments/"
|
||||
self.stdout.write(
|
||||
f"[INFO] Processing attachments under prefix '{prefix}' ..."
|
||||
)
|
||||
|
||||
continuation_token = None
|
||||
total_updated = 0
|
||||
|
||||
while True:
|
||||
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
|
||||
if continuation_token:
|
||||
list_kwargs["ContinuationToken"] = continuation_token
|
||||
|
||||
response = s3_client.list_objects_v2(**list_kwargs)
|
||||
|
||||
# If no objects found under this prefix, break out of the loop
|
||||
if "Contents" not in response:
|
||||
break
|
||||
|
||||
for obj in response["Contents"]:
|
||||
key = obj["Key"]
|
||||
|
||||
# Skip if it's a folder
|
||||
if key.endswith("/"):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get existing metadata
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||
|
||||
# Read first ~1KB for MIME detection
|
||||
partial_obj = s3_client.get_object(
|
||||
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
|
||||
)
|
||||
partial_data = partial_obj["Body"].read()
|
||||
|
||||
# Detect MIME type
|
||||
magic_mime_type = mime_detector.from_buffer(partial_data)
|
||||
|
||||
# Update ContentType
|
||||
s3_client.copy_object(
|
||||
Bucket=bucket_name,
|
||||
CopySource={"Bucket": bucket_name, "Key": key},
|
||||
Key=key,
|
||||
ContentType=magic_mime_type,
|
||||
Metadata=head_resp.get("Metadata", {}),
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
total_updated += 1
|
||||
|
||||
except Exception as exc: # noqa
|
||||
self.stderr.write(
|
||||
f"[ERROR] Could not update ContentType for {key}: {exc}"
|
||||
)
|
||||
|
||||
if response.get("IsTruncated"):
|
||||
continuation_token = response.get("NextContinuationToken")
|
||||
else:
|
||||
break
|
||||
|
||||
if total_updated > 0:
|
||||
self.stdout.write(
|
||||
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-10-25 11:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_fix_users_duplicate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
),
|
||||
]
|
||||
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'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Declare and configure the models for the impress core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import hashlib
|
||||
import smtplib
|
||||
@@ -26,8 +27,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
|
||||
@@ -72,6 +73,9 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||
|
||||
|
||||
class LinkReachChoices(models.TextChoices):
|
||||
"""Defines types of access for links"""
|
||||
|
||||
@@ -86,6 +90,16 @@ class LinkReachChoices(models.TextChoices):
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -123,21 +137,50 @@ class BaseModel(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
if not email:
|
||||
return None
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif (
|
||||
self.filter(email=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
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,
|
||||
@@ -189,7 +232,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -236,6 +279,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):
|
||||
@@ -333,11 +383,18 @@ class Document(BaseModel):
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
default=LinkReachChoices.AUTHENTICATED,
|
||||
default=LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -493,7 +550,8 @@ class Document(BaseModel):
|
||||
# Compute version roles before adding link roles because we don't
|
||||
# want anonymous users to access versions (we wouldn't know from
|
||||
# which date to allow them anyway)
|
||||
can_get_versions = bool(roles)
|
||||
# Anonymous users should also not see document accesses
|
||||
has_role = bool(roles)
|
||||
|
||||
# Add role provided by the document link
|
||||
if self.link_reach == LinkReachChoices.PUBLIC or (
|
||||
@@ -504,62 +562,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 {
|
||||
"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,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"accesses_view": has_role,
|
||||
"ai_transform": can_update,
|
||||
"ai_translate": can_update,
|
||||
"attachment_upload": can_update,
|
||||
"collaboration_auth": can_get,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"invite_owner": RoleChoices.OWNER in roles,
|
||||
"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": can_get_versions,
|
||||
"versions_retrieve": can_get_versions,
|
||||
"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):
|
||||
@@ -594,6 +676,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."""
|
||||
|
||||
@@ -669,15 +782,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,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
@@ -844,6 +957,8 @@ class Invitation(BaseModel):
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -864,7 +979,10 @@ class Invitation(BaseModel):
|
||||
super().clean()
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
if (
|
||||
User.objects.filter(email=self.email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
@@ -880,8 +998,6 @@ class Invitation(BaseModel):
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
can_delete = False
|
||||
can_update = False
|
||||
roles = []
|
||||
|
||||
if user.is_authenticated:
|
||||
@@ -896,17 +1012,13 @@ class Invitation(BaseModel):
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
can_delete = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
|
||||
can_update = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
is_admin_or_owner = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": bool(roles),
|
||||
"destroy": is_admin_or_owner,
|
||||
"update": is_admin_or_owner,
|
||||
"partial_update": is_admin_or_owner,
|
||||
"retrieve": is_admin_or_owner,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reseting 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,9 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import random
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -62,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email=None)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
user_info = {"sub": "123"}
|
||||
if random.choice([True, False]):
|
||||
user_info["email"] = None
|
||||
return user_info
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub and email didn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
@@ -75,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
@@ -91,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
@@ -128,11 +191,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 +226,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 +319,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 +424,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 +448,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"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Unit test for `update_files_content_type_metadata` command.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import call_command
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_files_content_type_metadata():
|
||||
"""
|
||||
Test that the command `update_files_content_type_metadata`
|
||||
fixes the ContentType of attachment in the storage.
|
||||
"""
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
|
||||
# Create files with a wrong ContentType
|
||||
keys = []
|
||||
for _ in range(10):
|
||||
doc_id = uuid.uuid4()
|
||||
factories.DocumentFactory(id=doc_id)
|
||||
key = f"{doc_id}/attachments/testfile.png"
|
||||
keys.append(key)
|
||||
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
|
||||
s3_client.put_object(
|
||||
Bucket=bucket_name,
|
||||
Key=key,
|
||||
Body=fake_png,
|
||||
ContentType="text/plain",
|
||||
Metadata={"owner": "None"},
|
||||
)
|
||||
|
||||
# Call the command that fixes the ContentType
|
||||
call_command("update_files_content_type_metadata")
|
||||
|
||||
for key in keys:
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||
assert (
|
||||
head_resp["ContentType"] == "image/png"
|
||||
), f"ContentType not fixed, got {head_resp['ContentType']!r}"
|
||||
|
||||
# Check that original metadata was preserved
|
||||
assert head_resp["Metadata"].get("owner") == "None"
|
||||
@@ -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,7 +693,9 @@ 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
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_path = response.json()["file"]
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
|
||||
assert file_head["Metadata"] == {"owner": "None"}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
@@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_invalid(client):
|
||||
@@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,content,extension",
|
||||
"name,content,extension,content_type",
|
||||
[
|
||||
("test.exe", b"text", "exe"),
|
||||
("test", b"text", "txt"),
|
||||
("test.aaaaaa", b"test", "txt"),
|
||||
("test.txt", PIXEL, "txt"),
|
||||
("test.py", b"#!/usr/bin/python", "py"),
|
||||
("test.exe", b"text", "exe", "text/plain"),
|
||||
("test", b"text", "txt", "text/plain"),
|
||||
("test.aaaaaa", b"test", "txt", "text/plain"),
|
||||
("test.txt", PIXEL, "txt", "image/png"),
|
||||
("test.py", b"#!/usr/bin/python", "py", "text/plain"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
|
||||
def test_api_documents_attachment_upload_fix_extension(
|
||||
name, content, extension, content_type
|
||||
):
|
||||
"""
|
||||
A file with no extension or a wrong extension is accepted and the extension
|
||||
is corrected in storage.
|
||||
@@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension)
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == content_type
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_empty_file():
|
||||
@@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == "application/octet-stream"
|
||||
|
||||
@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "my document"
|
||||
assert document.link_reach == "restricted"
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
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.api.serializers import ServerCreateDocumentSerializer
|
||||
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_md():
|
||||
"""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_md):
|
||||
"""
|
||||
It should be possible to create a document on behalf of a pre-existing user
|
||||
by passing their sub and email.
|
||||
"""
|
||||
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_md.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_md):
|
||||
"""
|
||||
It should be possible to create a document on behalf of new users by
|
||||
passing their unknown sub and 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_md.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"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
|
||||
)
|
||||
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
It should be possible to create a document on behalf of a pre-existing user for
|
||||
who the sub was not found if the settings allow it. This edge case should not
|
||||
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
|
||||
users sub on each login for example...
|
||||
"""
|
||||
user = factories.UserFactory(language="en-us")
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.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"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS=False,
|
||||
)
|
||||
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
When a user does not match an existing sub and fallback to matching on email is
|
||||
not allowed in settings, it should raise an error if the email is already used by
|
||||
a registered user and duplicate emails are not allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"email": [
|
||||
(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
]
|
||||
}
|
||||
assert mock_convert_md.called is False
|
||||
assert Document.objects.exists() is False
|
||||
assert Invitation.objects.exists() is False
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
@override_settings(
|
||||
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS=True,
|
||||
)
|
||||
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
When a user does not match an existing sub and fallback to matching on email is
|
||||
not allowed in settings, it should be possible to create a new user with the same
|
||||
email as an existing user if the settings allow it (identification is still done
|
||||
via the sub in this case).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
mock_convert_md.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 == user.email
|
||||
assert invitation.role == "owner"
|
||||
|
||||
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
|
||||
|
||||
# The creator field on the document should be set when the user is created
|
||||
user = User.objects.create(email=user.email, password="!")
|
||||
document.refresh_from_db()
|
||||
assert document.creator == user
|
||||
|
||||
|
||||
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
|
||||
def test_api_documents_create_for_owner_with_default_language(
|
||||
mock_send, mock_convert_md
|
||||
):
|
||||
"""The default language from settings should apply by default."""
|
||||
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 == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
assert mock_send.call_args[0][3] == "de-de"
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
|
||||
"""
|
||||
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_md.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_md,
|
||||
):
|
||||
"""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_md.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_md,
|
||||
):
|
||||
"""In case of converter error, a 400 error should be raised."""
|
||||
|
||||
mock_convert_md.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_md.assert_called_once_with("Document content")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Could not convert content"]}
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_empty_content():
|
||||
"""The content should not be empty or a 400 error should be raised."""
|
||||
|
||||
data = {
|
||||
"title": "My 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 response.json() == {
|
||||
"content": [
|
||||
"This field may not be blank.",
|
||||
],
|
||||
}
|
||||
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,7 +158,7 @@ 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
|
||||
retrieve related attachments.
|
||||
@@ -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
|
||||
@@ -21,12 +21,18 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"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,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
@@ -34,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"),
|
||||
}
|
||||
|
||||
@@ -77,12 +85,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"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,
|
||||
"manage_accesses": 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",
|
||||
@@ -90,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 (
|
||||
@@ -164,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"),
|
||||
}
|
||||
|
||||
@@ -253,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
|
||||
|
||||
@@ -264,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"),
|
||||
}
|
||||
|
||||
@@ -356,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
|
||||
|
||||
@@ -367,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"),
|
||||
}
|
||||
|
||||
@@ -477,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
|
||||
|
||||
@@ -488,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]
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
|
||||
47
src/backend/core/tests/test_api_config.py
Normal file
47
src/backend/core/tests/test_api_config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
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/",
|
||||
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
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/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
"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 == ""
|
||||
|
||||
|
||||
@@ -83,12 +90,17 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"link_configuration": False,
|
||||
"collaboration_auth": False,
|
||||
"destroy": False,
|
||||
"manage_accesses": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"link_configuration": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
@@ -115,12 +127,17 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -147,12 +164,17 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -168,12 +190,17 @@ def test_models_documents_get_abilities_owner():
|
||||
access = factories.UserDocumentAccessFactory(role="owner", user=user)
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"manage_accesses": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -188,12 +215,17 @@ def test_models_documents_get_abilities_administrator():
|
||||
access = factories.UserDocumentAccessFactory(role="administrator")
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"manage_accesses": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -211,12 +243,17 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -236,12 +273,17 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -262,12 +304,17 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -380,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
|
||||
@@ -394,8 +441,8 @@ 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
|
||||
|
||||
@@ -412,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
|
||||
@@ -429,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
|
||||
|
||||
@@ -448,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
|
||||
@@ -464,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)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core import exceptions
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -60,7 +62,7 @@ def test_models_invitations_role_among_choices():
|
||||
factories.InvitationFactory(role="boss")
|
||||
|
||||
|
||||
def test_models_invitations__is_expired(settings):
|
||||
def test_models_invitations_is_expired():
|
||||
"""
|
||||
The 'is_expired' property should return False until validity duration
|
||||
is exceeded and True afterwards.
|
||||
@@ -68,13 +70,16 @@ def test_models_invitations__is_expired(settings):
|
||||
expired_invitation = factories.InvitationFactory()
|
||||
assert expired_invitation.is_expired is False
|
||||
|
||||
settings.INVITATION_VALIDITY_DURATION = 1
|
||||
time.sleep(1)
|
||||
not_late = timezone.now() + timedelta(seconds=604799)
|
||||
with mock.patch("django.utils.timezone.now", return_value=not_late):
|
||||
assert expired_invitation.is_expired is False
|
||||
|
||||
assert expired_invitation.is_expired is True
|
||||
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
|
||||
with mock.patch("django.utils.timezone.now", return_value=too_late):
|
||||
assert expired_invitation.is_expired is True
|
||||
|
||||
|
||||
def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
|
||||
"""
|
||||
Upon creating a new user, invitations linked to the email
|
||||
should be converted to accesses and then deleted.
|
||||
@@ -109,7 +114,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
).exists() # the other invitation remains
|
||||
|
||||
|
||||
def test_models_invitation__new_user__filter_expired_invitations():
|
||||
def test_models_invitationd_new_user_filter_expired_invitations():
|
||||
"""
|
||||
Upon creating a new identity, valid invitations should be converted into accesses
|
||||
and expired invitations should remain unchanged.
|
||||
@@ -139,8 +144,8 @@ def test_models_invitation__new_user__filter_expired_invitations():
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
|
||||
def test_models_invitation__new_user__user_creation_constant_num_queries(
|
||||
@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
|
||||
):
|
||||
"""
|
||||
@@ -235,7 +240,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"retrieve": False,
|
||||
"partial_update": False,
|
||||
"update": False,
|
||||
}
|
||||
@@ -260,7 +265,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"retrieve": False,
|
||||
"partial_update": False,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
30
src/backend/core/tests/test_settings.py
Normal file
30
src/backend/core/tests/test_settings.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from impress.settings import Base
|
||||
|
||||
|
||||
def test_invalid_settings_oidc_email_configuration():
|
||||
"""
|
||||
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
|
||||
should not be both set to True simultaneously.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
# The validation is performed during post_setup
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
# Check the exception message
|
||||
assert str(excinfo.value) == (
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -223,6 +221,7 @@ class Base(Configuration):
|
||||
|
||||
# Languages
|
||||
LANGUAGE_CODE = values.Value("en-us")
|
||||
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
|
||||
|
||||
DRF_NESTED_MULTIPART_PARSER = {
|
||||
# output of parser is converted to querydict
|
||||
@@ -236,6 +235,7 @@ class Base(Configuration):
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -352,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)
|
||||
@@ -370,7 +372,33 @@ 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
|
||||
)
|
||||
|
||||
# Posthog
|
||||
POSTHOG_KEY = values.DictValue(
|
||||
None, environ_name="POSTHOG_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
# Crisp
|
||||
CRISP_WEBSITE_ID = values.Value(
|
||||
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -451,9 +479,34 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification (see previous setting).
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
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)
|
||||
@@ -469,17 +522,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):
|
||||
@@ -529,6 +639,15 @@ class Base(Configuration):
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
@@ -575,23 +694,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",
|
||||
]
|
||||
@@ -622,7 +724,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
|
||||
|
||||
352
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
352
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,352 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-16 19: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-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: de\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr "Benutzerkonto ist deaktiviert"
|
||||
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
|
||||
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr "unter"
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr "Name"
|
||||
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr "Kurzbezeichnung"
|
||||
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr "Admin E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
|
||||
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr "Status des Teammitgliedes"
|
||||
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
|
||||
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr "aktiviert"
|
||||
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
|
||||
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
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.
@@ -1,9 +1,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Project-Id-Version: lasuite-docs\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: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-16 19:53\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -11,345 +11,342 @@ msgstr ""
|
||||
"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-Project: lasuite-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: fr\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: core/admin.py:46
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:58
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/serializers.py:253
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:256
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:262
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr "Éditeur"
|
||||
|
||||
#: core/models.py:71
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:72
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:80
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: core/models.py:84
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: core/models.py:86
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:106
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
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."
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:149
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:157
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:171
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:176
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:181
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:200
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:332 core/models.py:638
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:348
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:351
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
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"
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: core/models.py:574
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, 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:"
|
||||
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:575
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:581
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:602
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:603
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621 core/models.py:810
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:639
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:640
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:641
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:643
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:652
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:791
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:792
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:827
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:844
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:845
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:862
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
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:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
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/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Proposé par La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
352
src/backend/locale/nl_NL/LC_MESSAGES/django.po
Normal file
352
src/backend/locale/nl_NL/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,352 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-16 19:53\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\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-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: nl\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.6.0"
|
||||
version = "2.0.1"
|
||||
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.41",
|
||||
"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.5",
|
||||
"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.44.1",
|
||||
"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.16.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.6.9",
|
||||
"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}
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
|
||||
const login = `user-e2e-${browserName}`;
|
||||
const password = `password-e2e-${browserName}`;
|
||||
|
||||
await expect(
|
||||
page.locator('.login-pf-page-header').getByText('impress'),
|
||||
).toBeVisible();
|
||||
|
||||
if (await page.getByLabel('Restart login').isVisible()) {
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
|
||||
await page.click('input[type="submit"]', { force: true });
|
||||
} else {
|
||||
await page.getByRole('textbox', { name: 'username' }).fill(login);
|
||||
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
|
||||
await page.click('input[type="submit"]', { force: true });
|
||||
await page.getByLabel('Restart login').click();
|
||||
}
|
||||
|
||||
await page.getByRole('textbox', { name: 'username' }).fill(login);
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
await page.click('input[type="submit"]', { force: true });
|
||||
};
|
||||
|
||||
export const randomName = (name: string, browserName: string, length: number) =>
|
||||
@@ -27,7 +27,6 @@ export const createDoc = async (
|
||||
docName: string,
|
||||
browserName: string,
|
||||
length: number,
|
||||
isPublic: boolean = false,
|
||||
) => {
|
||||
const randomDocs = randomName(docName, browserName, length);
|
||||
|
||||
@@ -37,34 +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();
|
||||
|
||||
if (isPublic) {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page.getByText('Doc private').click();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
force: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Public'),
|
||||
).toBeVisible();
|
||||
}
|
||||
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,
|
||||
@@ -77,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);
|
||||
@@ -92,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;
|
||||
};
|
||||
@@ -114,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();
|
||||
@@ -161,7 +147,7 @@ export const mockedDocument = async (page: Page, json: object) => {
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
accesses_manage: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user