mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
404 Commits
v.3.4.2
...
sbl-public
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270b374a17 | ||
|
|
ea3a4a6da3 | ||
|
|
b78ad27a71 | ||
|
|
e4b8ffb304 | ||
|
|
78c7ab247b | ||
|
|
b0bd6e2c01 | ||
|
|
37527416f2 | ||
|
|
30bc959340 | ||
|
|
a73d9c1c78 | ||
|
|
a920daf05b | ||
|
|
ff88465398 | ||
|
|
3617e4f7b8 | ||
|
|
efaec45bfd | ||
|
|
715d88ba3c | ||
|
|
7d64d79eeb | ||
|
|
2e66b87dab | ||
|
|
fb368ef86f | ||
|
|
e340463d35 | ||
|
|
344e9a83e4 | ||
|
|
48aa4971ec | ||
|
|
d47b5e6a90 | ||
|
|
c24f46067b | ||
|
|
f5a9ef2643 | ||
|
|
780bcb360a | ||
|
|
65d572ccd6 | ||
|
|
4644bb4f47 | ||
|
|
de3dfbb0c7 | ||
|
|
b0e7a511cb | ||
|
|
044c1495a9 | ||
|
|
6f282ec5d6 | ||
|
|
580d25b79f | ||
|
|
a48f61e583 | ||
|
|
331a94ad2f | ||
|
|
01c31ddd74 | ||
|
|
bf978b5376 | ||
|
|
24460ffc3a | ||
|
|
d721b97f68 | ||
|
|
3228f65092 | ||
|
|
6ba473f858 | ||
|
|
72238c1ab6 | ||
|
|
1d9c2a8118 | ||
|
|
f4bdde7e59 | ||
|
|
4dc3322b0d | ||
|
|
23216d549e | ||
|
|
2f612dbc2f | ||
|
|
bbf834fb6e | ||
|
|
4cf0e15406 | ||
|
|
31bd475418 | ||
|
|
08fb191e6b | ||
|
|
a49f3b6b32 | ||
|
|
bd9a3334db | ||
|
|
96299f4b7f | ||
|
|
52bd31c0d5 | ||
|
|
35be4be158 | ||
|
|
05aa225aed | ||
|
|
d65d0d1450 | ||
|
|
b11d3acd01 | ||
|
|
8091cbca23 | ||
|
|
12cc79b640 | ||
|
|
af15e77713 | ||
|
|
99131dc917 | ||
|
|
90651a8ea6 | ||
|
|
9c575e397c | ||
|
|
a6b472aa51 | ||
|
|
9fcc221b33 | ||
|
|
acdde81a3d | ||
|
|
9b03754f88 | ||
|
|
0805216cc6 | ||
|
|
5e398e8e79 | ||
|
|
00ae7fdd60 | ||
|
|
8036f16cc3 | ||
|
|
54fe70d662 | ||
|
|
1e37007be9 | ||
|
|
77df9783b7 | ||
|
|
350fe17918 | ||
|
|
a0ddc6ba0c | ||
|
|
92d3f634cb | ||
|
|
c06bc6fd21 | ||
|
|
80ee409da4 | ||
|
|
7475b7c3bc | ||
|
|
c13f0e97bb | ||
|
|
f11543094a | ||
|
|
b1fb400d70 | ||
|
|
50848b3410 | ||
|
|
9aeedd1d03 | ||
|
|
f7d4e6810b | ||
|
|
b740ffa52c | ||
|
|
f555e36e98 | ||
|
|
de11ab508f | ||
|
|
dc2fe4905b | ||
|
|
2864669dde | ||
|
|
7dae3a3c02 | ||
|
|
bdf62e2172 | ||
|
|
29104dfe2d | ||
|
|
785c9b21cf | ||
|
|
3fee1f2081 | ||
|
|
5f9968d81e | ||
|
|
f7baf238e3 | ||
|
|
bab42efd08 | ||
|
|
175d80db16 | ||
|
|
f8b8390758 | ||
|
|
a1463e0a10 | ||
|
|
0b555eed9f | ||
|
|
1bf810d596 | ||
|
|
48e1370ba3 | ||
|
|
b13571c6df | ||
|
|
a2a63cd13e | ||
|
|
3ebb62d786 | ||
|
|
0caee61d86 | ||
|
|
10a319881d | ||
|
|
26620f3471 | ||
|
|
0d0e17c8d5 | ||
|
|
257de6d068 | ||
|
|
5a4c02a978 | ||
|
|
0090ccc981 | ||
|
|
d403878f8c | ||
|
|
191b046641 | ||
|
|
aeac49d760 | ||
|
|
b5dcbbb057 | ||
|
|
2e64298ff4 | ||
|
|
8dad9ea6c4 | ||
|
|
3ae8046ffc | ||
|
|
a4e3168682 | ||
|
|
c8955133a4 | ||
|
|
b069310bf0 | ||
|
|
1292c33a58 | ||
|
|
bf68a5ae40 | ||
|
|
8799b4aa2f | ||
|
|
d96abb1ccf | ||
|
|
dc12a99d4a | ||
|
|
82a0c1a770 | ||
|
|
a758254b60 | ||
|
|
6314cb3a18 | ||
|
|
3e410e3519 | ||
|
|
aba7959344 | ||
|
|
3d45c7c215 | ||
|
|
cdb26b480a | ||
|
|
23a0f2761f | ||
|
|
0d596e338c | ||
|
|
3ab01c98c8 | ||
|
|
6445c05e29 | ||
|
|
b9b25eb1f6 | ||
|
|
de157b4f52 | ||
|
|
e5581e52f7 | ||
|
|
b91840c819 | ||
|
|
a9b77fb9a7 | ||
|
|
66f83db0e5 | ||
|
|
f9ff578c6b | ||
|
|
1372438f8e | ||
|
|
c5d5d3dec4 | ||
|
|
ad16c0843c | ||
|
|
78a6307656 | ||
|
|
d7d468f51f | ||
|
|
eb71028f6b | ||
|
|
39c22b074d | ||
|
|
d5c3f248a5 | ||
|
|
91217b3c4f | ||
|
|
ab271bc90d | ||
|
|
82e1783317 | ||
|
|
aa2b9ed5f2 | ||
|
|
1c96d645ba | ||
|
|
2f010cf36d | ||
|
|
9d3c1eb9d5 | ||
|
|
08f3ceaf3f | ||
|
|
b1d033edc9 | ||
|
|
192fa76b54 | ||
|
|
b667200ebd | ||
|
|
294922f966 | ||
|
|
8b73aa3644 | ||
|
|
dd56a8abeb | ||
|
|
145c688830 | ||
|
|
950d215632 | ||
|
|
7d5cc4e84b | ||
|
|
3e5bcf96ea | ||
|
|
fe24c00178 | ||
|
|
aca334f81f | ||
|
|
2003e41c22 | ||
|
|
5ebdf4b4d4 | ||
|
|
35e771a1ce | ||
|
|
2b5a9e1af8 | ||
|
|
a833fdc7a1 | ||
|
|
b3cc2bf833 | ||
|
|
18feab10cb | ||
|
|
2777488d24 | ||
|
|
a11258f778 | ||
|
|
33647f124f | ||
|
|
e339cda5c6 | ||
|
|
4ce65c654f | ||
|
|
c048b2ae95 | ||
|
|
5908afb098 | ||
|
|
e2298a3658 | ||
|
|
278eb233e9 | ||
|
|
b056dbfad4 | ||
|
|
771ef2417f | ||
|
|
8d5262c2f2 | ||
|
|
1125f441dc | ||
|
|
16f2de4c75 | ||
|
|
f19fa93600 | ||
|
|
af3d90db3b | ||
|
|
127c90ca5f | ||
|
|
fa7cf7a594 | ||
|
|
6523165ea0 | ||
|
|
de4d11732f | ||
|
|
37138c1a23 | ||
|
|
2c1a9ff74f | ||
|
|
31389bcae2 | ||
|
|
f772801fd0 | ||
|
|
390a615f48 | ||
|
|
5bdf5d2210 | ||
|
|
ed336558ac | ||
|
|
4fbd588198 | ||
|
|
546f97c956 | ||
|
|
af01c6e466 | ||
|
|
8023720da3 | ||
|
|
91eba31735 | ||
|
|
45d6c1beef | ||
|
|
dc25f3f39c | ||
|
|
529e7f1737 | ||
|
|
51c5c4ee63 | ||
|
|
72f098c667 | ||
|
|
3b08ba4de1 | ||
|
|
590b67fd71 | ||
|
|
b3980e7bf1 | ||
|
|
e3b2fdbdf5 | ||
|
|
314a7fa7b0 | ||
|
|
93227466d2 | ||
|
|
db7ae350ec | ||
|
|
236c8df5ae | ||
|
|
ae1b05189e | ||
|
|
431c331154 | ||
|
|
5184723862 | ||
|
|
ca10fb9a12 | ||
|
|
59e875764c | ||
|
|
7ed46ab225 | ||
|
|
18f4ab880f | ||
|
|
e71c45077d | ||
|
|
14c84f000e | ||
|
|
6cc42636e5 | ||
|
|
cc4bed6f8e | ||
|
|
d8f90c04bd | ||
|
|
1fdf70bdcf | ||
|
|
8ab21ef00d | ||
|
|
f337a2a8f2 | ||
|
|
3607faa475 | ||
|
|
0ea7dd727f | ||
|
|
6aca40a034 | ||
|
|
ee3b05cb55 | ||
|
|
c23ff546d8 | ||
|
|
a751f1255a | ||
|
|
8ee50631f3 | ||
|
|
e5e5fba0b3 | ||
|
|
0894bcdca5 | ||
|
|
75da342058 | ||
|
|
1ed01fd64b | ||
|
|
e4aa85be83 | ||
|
|
2dc1e07b42 | ||
|
|
fbdeb90113 | ||
|
|
b773f09792 | ||
|
|
d8c9283dd1 | ||
|
|
1e39d17914 | ||
|
|
ecd2f97cf5 | ||
|
|
90624e83f5 | ||
|
|
5fc002658c | ||
|
|
dfd5dc1545 | ||
|
|
69e7235f75 | ||
|
|
942c90c29f | ||
|
|
c5f0142671 | ||
|
|
7f37d3bda4 | ||
|
|
7033d0ecf7 | ||
|
|
0dd6818e91 | ||
|
|
eb225fc86f | ||
|
|
b893a29138 | ||
|
|
a812580d6c | ||
|
|
1062e38c92 | ||
|
|
62e122b05f | ||
|
|
32bc2890e0 | ||
|
|
3c3686dc7e | ||
|
|
ab90611c36 | ||
|
|
f9c08cf5ec | ||
|
|
2155c2ff1f | ||
|
|
ef08ba3a00 | ||
|
|
7a903041f8 | ||
|
|
4f2e07f949 | ||
|
|
8c1e95c587 | ||
|
|
20161fd6db | ||
|
|
e827cfeee1 | ||
|
|
eab2a75bff | ||
|
|
cd84751cb9 | ||
|
|
1d20a8b0a7 | ||
|
|
8a310d004b | ||
|
|
9f9fae96e5 | ||
|
|
9cb2b6a6fb | ||
|
|
0a1eaa3c40 | ||
|
|
da72a1601a | ||
|
|
9a51e02cd7 | ||
|
|
4184c339eb | ||
|
|
3688591dd1 | ||
|
|
25783182b8 | ||
|
|
80a62bcbc1 | ||
|
|
ede0a77665 | ||
|
|
8a8a1460e5 | ||
|
|
0ac9f059b6 | ||
|
|
179a84150b | ||
|
|
084d0c1089 | ||
|
|
c9a6c4d4c6 | ||
|
|
9db7d0af8d | ||
|
|
9135dff088 | ||
|
|
cc4c67d15b | ||
|
|
63a2bde11e | ||
|
|
b317a2a596 | ||
|
|
39ef6d10ff | ||
|
|
961ae3c39e | ||
|
|
726b50d6b5 | ||
|
|
814eb1f1a1 | ||
|
|
648528499c | ||
|
|
474e5ac0c0 | ||
|
|
a799d77643 | ||
|
|
2e04b63d2d | ||
|
|
eec419bdba | ||
|
|
baa5630344 | ||
|
|
e7b551caa4 | ||
|
|
4dfc1584bd | ||
|
|
09eddfc339 | ||
|
|
75f2e547e0 | ||
|
|
d1cbdfd819 | ||
|
|
0b64417058 | ||
|
|
57a505a80c | ||
|
|
21ee38c218 | ||
|
|
09de014a43 | ||
|
|
8d42149304 | ||
|
|
2451a6a322 | ||
|
|
d5c9eaca5a | ||
|
|
1491012969 | ||
|
|
9dcf478dd3 | ||
|
|
586825aafa | ||
|
|
247550fc13 | ||
|
|
781c85b66b | ||
|
|
64f967cd29 | ||
|
|
1eee24dc19 | ||
|
|
ff9e13ca03 | ||
|
|
7758e64f40 | ||
|
|
4ab9edcd57 | ||
|
|
0892c05321 | ||
|
|
2375bc136c | ||
|
|
e1c2053697 | ||
|
|
58f68d86e1 | ||
|
|
7c97719907 | ||
|
|
d0c9de9d96 | ||
|
|
81f3997628 | ||
|
|
0cf8b9da1a | ||
|
|
7be761ce84 | ||
|
|
5181bba083 | ||
|
|
f434d78b5d | ||
|
|
e07f709dd4 | ||
|
|
afbacb0a24 | ||
|
|
409e073192 | ||
|
|
886dcb75d5 | ||
|
|
bb4d2a9fea | ||
|
|
5e5054282e | ||
|
|
f497e75426 | ||
|
|
97ab13ded6 | ||
|
|
99d674c615 | ||
|
|
1cdb6b62c8 | ||
|
|
2bf53301d2 | ||
|
|
ec84f31bc7 | ||
|
|
7813219b86 | ||
|
|
cecb4f5756 | ||
|
|
63efe40a7b | ||
|
|
e26c3dff35 | ||
|
|
f5f9d8a877 | ||
|
|
e7709badbb | ||
|
|
2a7c0ef800 | ||
|
|
155e7dfe22 | ||
|
|
afa48b6675 | ||
|
|
f12d30cffa | ||
|
|
30dfea744a | ||
|
|
2cbe363a5f | ||
|
|
7f450e8aa8 | ||
|
|
7021c0f849 | ||
|
|
e8d18d85e9 | ||
|
|
67a195f89c | ||
|
|
09b6fef63f | ||
|
|
11d0bafc94 | ||
|
|
1ae831cabd | ||
|
|
f1c2219270 | ||
|
|
8c9380c356 | ||
|
|
3ff6d2541c | ||
|
|
34ce276222 | ||
|
|
04273c3b3e | ||
|
|
0b301b95c8 | ||
|
|
228bdf733e | ||
|
|
bbf48f088f | ||
|
|
b28ff8f632 | ||
|
|
14b7cdf561 | ||
|
|
c534fed196 | ||
|
|
c1a740b7d4 | ||
|
|
83f2b3886e | ||
|
|
966e514c5a | ||
|
|
ef6d6c6a59 | ||
|
|
e79f3281b1 | ||
|
|
b78550b513 | ||
|
|
5a23c97681 | ||
|
|
040eddbe6b | ||
|
|
f2e54308d2 |
24
.github/actions/free-disk-space/action.yml
vendored
Normal file
24
.github/actions/free-disk-space/action.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: 'Free Disk Space'
|
||||
description: 'Free up disk space by removing large preinstalled items and cleaning up Docker'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Free disk space (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Disk usage before cleanup:"
|
||||
df -h
|
||||
|
||||
# Remove large preinstalled items that are not used on GitHub-hosted runners
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /usr/local/lib/android || true
|
||||
|
||||
# Clean up Docker
|
||||
docker system prune -af || true
|
||||
docker volume prune -f || true
|
||||
|
||||
echo "Disk usage after cleanup:"
|
||||
df -h
|
||||
3
.github/workflows/crowdin_upload.yml
vendored
3
.github/workflows/crowdin_upload.yml
vendored
@@ -23,9 +23,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
|
||||
42
.github/workflows/docker-hub.yml
vendored
42
.github/workflows/docker-hub.yml
vendored
@@ -31,8 +31,11 @@ jobs:
|
||||
images: lasuite/impress-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -46,9 +49,15 @@ jobs:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
-
|
||||
name: Cleanup Docker after build
|
||||
if: always()
|
||||
run: |
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -64,8 +73,11 @@ jobs:
|
||||
images: lasuite/impress-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -82,9 +94,15 @@ jobs:
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
-
|
||||
name: Cleanup Docker after build
|
||||
if: always()
|
||||
run: |
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
|
||||
build-and-push-y-provider:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -100,7 +118,7 @@ jobs:
|
||||
images: lasuite/impress-y-provider
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
@@ -116,16 +134,22 @@ jobs:
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
-
|
||||
name: Cleanup Docker after build
|
||||
if: always()
|
||||
run: |
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
|
||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
HELMFILE=src/helm/helmfile.yaml.gotmpl
|
||||
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
||||
helmfile -e $env lint -f $HELMFILE || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
done
|
||||
|
||||
63
.github/workflows/impress-frontend.yml
vendored
63
.github/workflows/impress-frontend.yml
vendored
@@ -80,11 +80,14 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||
|
||||
- name: Free disk space before Docker
|
||||
uses: ./.github/actions/free-disk-space
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
@@ -101,7 +104,7 @@ jobs:
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-e2e-chromium
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -119,11 +122,14 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Free disk space before Docker
|
||||
uses: ./.github/actions/free-disk-space
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
@@ -136,3 +142,54 @@ jobs:
|
||||
name: playwright-other-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
bundle-size-check:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@v2
|
||||
with:
|
||||
filters: |
|
||||
lock:
|
||||
- 'src/frontend/**/yarn.lock'
|
||||
app:
|
||||
- 'src/frontend/apps/impress/**'
|
||||
|
||||
- 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: Setup Node.js
|
||||
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Check bundle size changes
|
||||
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||
uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
build-script: "app:build"
|
||||
pattern: "apps/impress/out/**/*.{css,js,html}"
|
||||
exclude: "{**/*.map,**/node_modules/**}"
|
||||
minimum-change-threshold: 500
|
||||
compression: "gzip"
|
||||
cwd: "./src/frontend"
|
||||
show-total: true
|
||||
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
|
||||
omit-unchanged: true
|
||||
install-script: "yarn install --frozen-lockfile"
|
||||
|
||||
21
.github/workflows/impress.yml
vendored
21
.github/workflows/impress.yml
vendored
@@ -19,20 +19,24 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
@@ -42,7 +46,7 @@ jobs:
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -66,7 +70,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install codespell
|
||||
run: pip install --user codespell
|
||||
- name: Check for typos
|
||||
@@ -75,6 +79,7 @@ jobs:
|
||||
--check-filenames \
|
||||
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||
--skip "./git/" \
|
||||
--skip "**/*.pdf" \
|
||||
--skip "**/*.po" \
|
||||
--skip "**/*.pot" \
|
||||
--skip "**/*.json" \
|
||||
@@ -87,11 +92,12 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
@@ -184,9 +190,10 @@ jobs:
|
||||
mc version enable impress/impress-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
27
.github/workflows/label_preview.yml
vendored
Normal file
27
.github/workflows/label_preview.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Label Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
|
||||
|
||||
You can use the existing account with these credentials:
|
||||
- username: `docs`
|
||||
- password: `docs`
|
||||
|
||||
You can also create a new account if you want to.
|
||||
|
||||
Once this Pull Request is merged, the preview will be destroyed.
|
||||
comment-tag: preview-url
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -40,10 +40,13 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
@@ -76,3 +79,6 @@ db.sqlite3
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Cursor rules
|
||||
.cursorrules
|
||||
|
||||
422
CHANGELOG.md
422
CHANGELOG.md
@@ -1,5 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
@@ -8,6 +6,320 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) redirecting system #1697
|
||||
- 📱(frontend) add comments for smaller device #1737
|
||||
- ✨(project) add custom js support via config #1759
|
||||
|
||||
### Changed
|
||||
|
||||
- 🥅(frontend) intercept 401 error on GET threads #1754
|
||||
- 🦺(frontend) check content type pdf on PdfBlock #1756
|
||||
- ✈️(frontend) pause Posthog when offline #1755
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix tables deletion #1752
|
||||
- 🐛(frontend) fix children not display when first resize #1753
|
||||
- 📝(doc) fix publiccode.yml syntax #1770
|
||||
|
||||
## [4.2.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow to create a new user in a marketing system #1707
|
||||
- ✨(backend) add async indexation of documents on save (or access save) #1276
|
||||
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
|
||||
- ✨(api) add API route to search for indexed documents in Find #1276
|
||||
- 🥅(frontend) add boundary error page #1728
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂(backend) stop throttling collaboration servers #1730
|
||||
- 🚸(backend) use unaccented full name for user search #1637
|
||||
- 🌐(backend) internationalize demo #1644
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️Improve keyboard accessibility for the document tree #1681
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) paste content with comments from another document #1732
|
||||
- 🐛(frontend) Select text + Go back one page crash the app #1733
|
||||
- 🐛(frontend) fix versioning conflict #1742
|
||||
|
||||
|
||||
## [4.1.0] - 2025-12-09
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(frontend) export html #1669
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) add skip to content button for keyboard accessibility #1624
|
||||
- ♿(frontend) fix toggle panel button a11y labels #1634
|
||||
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
|
||||
- ⚡️(frontend) improve Comments feature #1687
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(nginx) fix / location to handle new static pages #1682
|
||||
- 🐛(frontend) rerendering during resize window #1715
|
||||
|
||||
## [4.0.0] - 2025-12-01
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ Add comments feature to the editor #1330
|
||||
- ✨(backend) Comments on text editor #1330
|
||||
- ✨(frontend) link to create new doc #1574
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(sw) stop to cache external resources likes videos #1655
|
||||
- 💥(frontend) upgrade to ui-kit v2 #1605
|
||||
- ⚡️(frontend) improve perf on upload and table of contents #1662
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve share modal button accessibility #1626
|
||||
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix toolbar not activated when reader #1640
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||
|
||||
## [3.10.0] - 2025-11-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(export) enable ODT export for documents #1524
|
||||
- ✨(frontend) improve mobile UX by showing subdocs count #1540
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
|
||||
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
|
||||
- ♻️(frontend) pdf embed use full width #1526
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
|
||||
- ♿(frontend) improve accessibility and styling of summary table #1528
|
||||
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
|
||||
- 🐛(frontend) fix alignment of side menu #1597
|
||||
- 🐛(frontend) fix fallback translations with Trans #1620
|
||||
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
|
||||
- 🐛(export) fix table cell alignment issue in exported documents #1582
|
||||
- 🐛(export) preserve image aspect ratio in PDF export #1622
|
||||
- 🐛(export) Export fails when paste with style #1552
|
||||
|
||||
### Security
|
||||
|
||||
- mitigate role escalation in the ask_for_access viewset #1580
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(backend) remove api managing templates
|
||||
|
||||
## [3.9.0] - 2025-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
|
||||
- ✨(frontend) ajustable left panel #1456
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||
- ♻️(backend) increase user short_name field length #1510
|
||||
- 🚸(frontend) separate viewers from editors #1509
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||
- 🐛(backend) fix trashbin list #1520
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
|
||||
- 🐛(backend) fix s3 version_id validation #1543
|
||||
- 🐛(frontend) retry check media status after page reload #1555
|
||||
- 🐛(frontend) fix Interlinking memory leak #1560
|
||||
- 🐛(frontend) button new doc UI fix #1557
|
||||
- 🐛(frontend) interlinking UI fix #1557
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(service-worker) fix sw registration and page reload logic #1500
|
||||
|
||||
## [3.8.1] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡️(backend) improve trashbin endpoint performance #1495
|
||||
- 🐛(backend) manage invitation partial update without email #1494
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
|
||||
- ♿ add missing aria-label to more options button on sub-docs #1481
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(backend) remove treebeard form for the document admin #1470
|
||||
|
||||
## [3.8.0] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
- ✨List and restore deleted docs #1450
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||
- ♻️(frontend) replace Arial font-family with token font #1411
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) enable enter key to open documentss #1354
|
||||
- ♿(frontend) improve modal a11y: structure, labels, title #1349
|
||||
- ♿improve NVDA navigation in DocShareModal #1396
|
||||
- ♿ improve accessibility by adding landmark roles to layout #1394
|
||||
- ♿ add document visible in list and openable via enter key #1365
|
||||
- ♿ add pdf outline property to enable bookmarks display #1368
|
||||
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
|
||||
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
|
||||
- ♿ remove redundant aria-label to avoid over-accessibility #1420
|
||||
- ♿ remove redundant aria-label on hidden icons and update tests #1432
|
||||
- ♿ improve semantic structure and aria roles of leftpanel #1431
|
||||
- ♿ add default background to left panel for better accessibility #1423
|
||||
- ♿ restyle checked checkboxes: removing strikethrough #1439
|
||||
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
|
||||
- ♿ update labels and shared document icon accessibility #1442
|
||||
- 🍱(frontend) Fonts GDPR compliants #1453
|
||||
- ♻️(service-worker) improve SW registration and update handling #1473
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) duplicate sub docs as root for reader users #1385
|
||||
- ⚗️(service-worker) remove index from cache first strategy #1395
|
||||
- 🐛(frontend) fix 404 page when reload 403 page #1402
|
||||
- 🐛(frontend) fix legacy role computation #1376
|
||||
- 🛂(frontend) block editing title when not allowed #1412
|
||||
- 🐛(frontend) scroll back to top when navigate to a document #1406
|
||||
- 🐛(frontend) fix export pdf emoji problem #1453
|
||||
- 🐛(frontend) fix attachment download filename #1447
|
||||
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
|
||||
- 🔒(frontend) prevent readers from changing callout emoji #1449
|
||||
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
|
||||
- 🐛(backend) filter invitation with case insensitive email #1457
|
||||
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
|
||||
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
|
||||
- ✨(frontend) load docs logo from public folder via url #1462
|
||||
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add API route to fetch document content #1206
|
||||
- ✨(frontend) doc emojis improvements #1381
|
||||
- add an EmojiPicker in the document tree and document title
|
||||
- remove emoji buttons in menus
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) configure throttle on every viewsets #1343
|
||||
- ⬆️ Bump eslint to V9 #1071
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿fix major accessibility issues reported by wave and axe #1344
|
||||
- ✨unify tab focus style for better visual consistency #1341
|
||||
- ✨improve modal a11y: structure, labels, and title #1349
|
||||
- ✨improve accessibility of cdoc content with correct aria tags #1271
|
||||
- ✨unify tab focus style for better visual consistency #1341
|
||||
- ♿hide decorative icons, label menus, avoid accessible name… #1362
|
||||
- ♻️(tilt) use helm dev-backend chart
|
||||
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
|
||||
- 🩹(frontend) handle properly emojis in interlinking #1381
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(frontend) remove multi column drop cursor #1370
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix callout emoji list #1366
|
||||
|
||||
## [3.6.0] - 2025-09-04
|
||||
|
||||
### Added
|
||||
|
||||
- 👷(CI) add bundle size check job #1268
|
||||
- ✨(frontend) use title first emoji as doc icon in tree #1289
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
|
||||
- ♿(frontend) improve accessibility:
|
||||
- 🌐(frontend) set html lang attribute dynamically #1248
|
||||
- ♿(frontend) inject language attribute to pdf export #1235
|
||||
- ♿(frontend) improve accessibility of search modal #1275
|
||||
- ♿(frontend) add correct attributes to icons #1255
|
||||
- 🎨(frontend) improve nav structure #1262
|
||||
- ♿️(frontend) keyboard interaction with menu #1244
|
||||
- ♿(frontend) improve header accessibility #1270
|
||||
- ♿(frontend) improve accessibility for decorative images in editor #1282
|
||||
- #1338
|
||||
- #1281
|
||||
- ♻️(backend) fallback to email identifier when no name #1298
|
||||
- 🐛(backend) allow ASCII characters in user sub field #1295
|
||||
- ⚡️(frontend) improve fallback width calculation #1333
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1263
|
||||
- 🐛(minio) fix user permission error with Minio and Windows #1263
|
||||
- 🐛(frontend) fix export when quote block and inline code #1319
|
||||
- 🐛(frontend) fix base64 font #1324
|
||||
- 🐛(backend) allow creator to delete subpages #1297
|
||||
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
|
||||
- 🐛(frontend) fix display bug on homepage #1332
|
||||
- 🐛link role update #1287
|
||||
|
||||
## [3.5.0] - 2025-07-31
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) Service Account support for K8s Resources in Helm Charts #780
|
||||
- ✨(backend) allow masking documents from the list view #1172
|
||||
- ✨(frontend) subdocs can manage link reach #1190
|
||||
- ✨(frontend) add duplicate action to doc tree #1175
|
||||
- ✨(frontend) Interlinking doc #904
|
||||
- ✨(frontend) add multi columns support for editor #1219
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) search on all docs if no children #1184
|
||||
- ♻️(frontend) redirect to doc after duplicate #1175
|
||||
- 🔧(project) change env.d system by using local files #1200
|
||||
- ⚡️(frontend) improve tree stability #1207
|
||||
- ⚡️(frontend) improve accessibility #1232
|
||||
- 🛂(frontend) block drag n drop when not desktop #1239
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
|
||||
- 🐛(frontend) fix empty left panel after deleting root doc #1197
|
||||
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
|
||||
- 🐛(frontend) 401 redirection overridden #1214
|
||||
- 🐛(frontend) include root parent in search #1243
|
||||
|
||||
## [3.4.2] - 2025-07-18
|
||||
|
||||
### Changed
|
||||
@@ -18,16 +330,16 @@ and this project adheres to
|
||||
|
||||
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||
|
||||
|
||||
## [3.4.1] - 2025-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🌐(frontend) keep simple tag during export #1154
|
||||
- 🐛(back) manage can-edit endpoint without created room
|
||||
- 🐛(back) manage can-edit endpoint without created room
|
||||
in the ws #1152
|
||||
- 🐛(frontend) fix action buttons not clickable #1162
|
||||
- 🐛(frontend) fix crash share modal on grid options #1174
|
||||
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
|
||||
|
||||
## [3.4.0] - 2025-07-09
|
||||
|
||||
@@ -42,12 +354,12 @@ and this project adheres to
|
||||
- ✨(backend) add ancestors links reach and role to document API #846
|
||||
- 📝(project) add troubleshoot doc #1066
|
||||
- 📝(project) add system-requirement doc #1066
|
||||
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
|
||||
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
|
||||
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||
attachment upload #1099
|
||||
- ✨(doc) add documentation to install with compose #855
|
||||
- ✨ Give priority to users connected to collaboration server
|
||||
(aka no websocket feature) #1093
|
||||
- ✨ Give priority to users connected to collaboration server
|
||||
(aka no websocket feature) #1093
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -73,7 +385,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove Beta from logo #1095
|
||||
|
||||
|
||||
## [3.3.0] - 2025-05-06
|
||||
|
||||
### Added
|
||||
@@ -99,13 +410,13 @@ and this project adheres to
|
||||
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(back) remove footer endpoint #948
|
||||
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
@@ -113,7 +424,6 @@ and this project adheres to
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
|
||||
## Added
|
||||
@@ -124,7 +434,7 @@ and this project adheres to
|
||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||
- 🌐(i18n) activate chinese and spanish languages #884
|
||||
- 🔧(backend) allow overwriting the data directory #893
|
||||
- ➕(backend) add `django-lasuite` dependency #839
|
||||
- ➕(backend) add `django-lasuite` dependency #839
|
||||
- ✨(frontend) advanced table features #908
|
||||
|
||||
## Changed
|
||||
@@ -141,7 +451,6 @@ and this project adheres to
|
||||
- 🐛(backend) race condition create doc #633
|
||||
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||
|
||||
|
||||
## [3.1.0] - 2025-04-07
|
||||
|
||||
## Added
|
||||
@@ -176,7 +485,6 @@ and this project adheres to
|
||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||
- 🔒️(back) restrict access to document accesses #801
|
||||
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
@@ -195,7 +503,6 @@ and this project adheres to
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
@@ -218,15 +525,14 @@ and this project adheres to
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) SVG export #706
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(action) fix notify-argocd workflow #713
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
@@ -241,7 +547,6 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
@@ -657,36 +962,47 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.4.2...main
|
||||
[v3.4.2]: https://github.com/numerique-gouv/impress/releases/v3.4.2
|
||||
[v3.4.1]: https://github.com/numerique-gouv/impress/releases/v3.4.1
|
||||
[v3.4.0]: https://github.com/numerique-gouv/impress/releases/v3.4.0
|
||||
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
|
||||
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
|
||||
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
|
||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[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
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.2.0...main
|
||||
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
|
||||
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
|
||||
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
|
||||
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
|
||||
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
|
||||
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||
|
||||
@@ -94,6 +94,14 @@ RUN chmod g=u /etc/passwd
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
|
||||
# when python is upgraded and the path to the certificate changes.
|
||||
# The space between print and the ( is intended otherwise the git lint is failing
|
||||
RUN mkdir /cert && \
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
|
||||
120
Makefile
120
Makefile
@@ -35,9 +35,13 @@ DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
DOCKER_USER := 0:0 # run containers as root on Windows
|
||||
else
|
||||
DOCKER_UID := $(shell id -u)
|
||||
DOCKER_GID := $(shell id -g)
|
||||
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
|
||||
endif
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
@@ -48,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -67,18 +71,18 @@ data/static:
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
create-env-local-files: ## create env.local files in env.d/development
|
||||
create-env-local-files:
|
||||
@touch env.d/development/crowdin.local
|
||||
@touch env.d/development/common.local
|
||||
@touch env.d/development/postgresql.local
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files
|
||||
create-env-local-files
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
@@ -89,13 +93,77 @@ post-bootstrap: \
|
||||
mails-build
|
||||
.PHONY: post-bootstrap
|
||||
|
||||
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@echo ""
|
||||
@echo "================================================================================"
|
||||
@echo ""
|
||||
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
|
||||
@echo ""
|
||||
@echo " This will set up your development environment with:"
|
||||
@echo " - Docker containers for all services"
|
||||
@echo " - Database migrations and static files"
|
||||
@echo " - Frontend dependencies and build"
|
||||
@echo " - Environment configuration files"
|
||||
@echo ""
|
||||
@echo " Services will be available at:"
|
||||
@echo " - Frontend: http://localhost:3000"
|
||||
@echo " - API: http://localhost:8071"
|
||||
@echo " - Admin: http://localhost:8071/admin"
|
||||
@echo ""
|
||||
@echo "================================================================================"
|
||||
@echo ""
|
||||
@echo "Starting bootstrap process..."
|
||||
else
|
||||
@echo "$(BOLD)"
|
||||
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
@echo "║ ║"
|
||||
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
|
||||
@echo "║ ║"
|
||||
@echo "║ This will set up your development environment with : ║"
|
||||
@echo "║ • Docker containers for all services ║"
|
||||
@echo "║ • Database migrations and static files ║"
|
||||
@echo "║ • Frontend dependencies and build ║"
|
||||
@echo "║ • Environment configuration files ║"
|
||||
@echo "║ ║"
|
||||
@echo "║ Services will be available at: ║"
|
||||
@echo "║ • Frontend: http://localhost:3000 ║"
|
||||
@echo "║ • API: http://localhost:8071 ║"
|
||||
@echo "║ • Admin: http://localhost:8071/admin ║"
|
||||
@echo "║ ║"
|
||||
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
@echo "$(RESET)"
|
||||
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
|
||||
endif
|
||||
@echo ""
|
||||
.PHONY: pre-beautiful-bootstrap
|
||||
|
||||
bootstrap: ## Prepare Docker developmentimages for the project
|
||||
post-beautiful-bootstrap: ## Display a success message after bootstrap
|
||||
@echo ""
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@echo "Bootstrap completed successfully!"
|
||||
@echo ""
|
||||
@echo "Next steps:"
|
||||
@echo " - Visit http://localhost:3000 to access the application"
|
||||
@echo " - Run 'make help' to see all available commands"
|
||||
else
|
||||
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
|
||||
@echo ""
|
||||
@echo "$(BOLD)Next steps:$(RESET)"
|
||||
@echo " • Visit http://localhost:3000 to access the application"
|
||||
@echo " • Run 'make help' to see all available commands"
|
||||
endif
|
||||
@echo ""
|
||||
.PHONY: post-beautiful-bootstrap
|
||||
|
||||
bootstrap: ## Prepare the project for local development
|
||||
bootstrap: \
|
||||
pre-beautiful-bootstrap \
|
||||
pre-bootstrap \
|
||||
build \
|
||||
post-bootstrap \
|
||||
run
|
||||
run \
|
||||
post-beautiful-bootstrap
|
||||
.PHONY: bootstrap
|
||||
|
||||
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||
@@ -179,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
index: ## index all documents to remote search
|
||||
@$(MANAGE) index
|
||||
.PHONY: index
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
@@ -258,20 +330,6 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
@@ -352,6 +410,10 @@ run-frontend-development: ## Run the frontend in development mode
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-test: ## Run the frontend tests
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn test
|
||||
.PHONY: frontend-test
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
@@ -382,6 +444,6 @@ bump-packages-version: ## bump the version of the project - VERSION_TYPE can be
|
||||
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
.PHONY: bump-packages-version
|
||||
|
||||
25
README.md
25
README.md
@@ -49,13 +49,24 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||
|
||||
### Self-host
|
||||
🚀 Docs is easy to install on your own servers
|
||||
|
||||
Available methods: Helm chart, Nix package
|
||||
#### 🚀 Docs is easy to install on your own servers
|
||||
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
|
||||
|
||||
In the works: Docker Compose, YunoHost
|
||||
#### 🌍 Known instances
|
||||
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||
| Url | Org | Public |
|
||||
| --- | --- | ------- |
|
||||
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
|
||||
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
|
||||
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
#### ⚠️ Advanced features
|
||||
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
@@ -130,6 +141,12 @@ To start all the services, except the frontend container, you can use the follow
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
To execute frontend tests & linting only
|
||||
```shellscript
|
||||
$ make frontend-test
|
||||
$ make frontend-lint
|
||||
```
|
||||
|
||||
**Adding content**
|
||||
|
||||
You can create a basic demo site by running this command:
|
||||
|
||||
23
UPGRADE.md
23
UPGRADE.md
@@ -16,6 +16,29 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [4.0.0] - 2025-11-26
|
||||
|
||||
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
|
||||
More information about the changes in the design system can be found here:
|
||||
- https://suitenumerique.github.io/cunningham/storybook/?path=/docs/migrating-from-v3-to-v4--docs
|
||||
- https://github.com/suitenumerique/docs/pull/1605
|
||||
- https://github.com/suitenumerique/docs/blob/main/docs/theming.md
|
||||
|
||||
- If you were using the `THEME_CUSTOMIZATION_FILE_PATH` and have overridden the header logo, you need to update your customization file to follow the new structure of the header, it is now:
|
||||
```json
|
||||
{
|
||||
...,
|
||||
"header": {
|
||||
"icon": {
|
||||
"src": "your_logo_src",
|
||||
"width": "your_logo_width",
|
||||
"height": "your_logo_height"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## [3.3.0] - 2025-05-22
|
||||
|
||||
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||
|
||||
@@ -39,9 +39,10 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
|
||||
k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres'])
|
||||
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
|
||||
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
|
||||
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
|
||||
@@ -38,6 +38,10 @@ function _set_user() {
|
||||
# options: docker compose command options
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
# Set DOCKER_USER for Windows compatibility with MinIO
|
||||
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
|
||||
export DOCKER_USER="0:0"
|
||||
fi
|
||||
|
||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
|
||||
6
bin/fernetkey
Executable file
6
bin/fernetkey
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
@@ -24,5 +24,6 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
ports:
|
||||
- "4444:4444"
|
||||
36
compose.yml
36
compose.yml
@@ -10,6 +10,7 @@ services:
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "15432:5432"
|
||||
|
||||
@@ -66,9 +67,16 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- impress
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -89,9 +97,14 @@ services:
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -102,6 +115,11 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- nginx
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -135,6 +153,7 @@ services:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin
|
||||
- env.d/development/crowdin.local
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
@@ -156,6 +175,7 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
ports:
|
||||
- "4444:4444"
|
||||
volumes:
|
||||
@@ -174,24 +194,23 @@ services:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
- env.d/development/kc_postgresql.local
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
|
||||
start_period: 5s
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
@@ -211,3 +230,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.test",
|
||||
"email": "user.test@chromium.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.test",
|
||||
"email": "user.test@webkit.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.test",
|
||||
"email": "user.test@firefox.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
@@ -2270,7 +2270,7 @@
|
||||
"cibaInterval": "5",
|
||||
"realmReusableOtpCode": "false"
|
||||
},
|
||||
"keycloakVersion": "20.0.1",
|
||||
"keycloakVersion": "26.3.2",
|
||||
"userManagedAccessAllowed": false,
|
||||
"clientProfiles": {
|
||||
"profiles": []
|
||||
|
||||
@@ -11,6 +11,9 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# increase max upload size
|
||||
client_max_body_size 10m;
|
||||
|
||||
# Disables server version feedback on pages and in headers
|
||||
server_tokens off;
|
||||
|
||||
@@ -68,7 +71,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /collaboration/api/ {
|
||||
location /collaboration/api/ {
|
||||
# Collaboration server
|
||||
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||
proxy_set_header Host $host;
|
||||
@@ -95,7 +98,7 @@ server {
|
||||
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
}
|
||||
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
@@ -109,4 +112,4 @@ server {
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ flowchart TD
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
Back -- REST API --> Find
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
211
docs/env.md
211
docs/env.md
@@ -6,103 +6,113 @@ Here we describe all environment variables that can be set for the docs applicat
|
||||
|
||||
These are the environment variables you can set for the `impress-backend` container.
|
||||
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||
| AI_MODEL | AI Model to use | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| COLLABORATION_API_URL | Collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
| DB_NAME | Name of the database | impress |
|
||||
| DB_PASSWORD | Password to authenticate with | pass |
|
||||
| DB_PORT | Port of the database | 5432 |
|
||||
| DB_USER | User to authenticate with | dinum |
|
||||
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||
| DJANGO_SECRET_KEY | Secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | Frontend theme to use | |
|
||||
| LANGUAGE_CODE | Default language | en-us |
|
||||
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| POSTHOG_KEY | Posthog key for analytics | |
|
||||
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||
| AI_MODEL | AI Model to use | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| COLLABORATION_API_URL | Collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
| DB_NAME | Name of the database | impress |
|
||||
| DB_PASSWORD | Password to authenticate with | pass |
|
||||
| DB_PORT | Port of the database | 5432 |
|
||||
| DB_USER | User to authenticate with | dinum |
|
||||
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||
| DJANGO_SECRET_KEY | Secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | Frontend theme to use | |
|
||||
| LANGUAGE_CODE | Default language | en-us |
|
||||
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
|
||||
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
|
||||
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| POSTHOG_KEY | Posthog key for analytics | |
|
||||
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
|
||||
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
|
||||
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
|
||||
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
|
||||
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
|
||||
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||
|
||||
|
||||
## impress-frontend image
|
||||
@@ -135,10 +145,11 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
||||
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||
|
||||
Packages with licences incompatible with the MIT licence:
|
||||
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
|
||||
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||
|
||||
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ services:
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
@@ -22,12 +22,12 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
restart: always
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Production
|
||||
- DJANGO_CONFIGURATION=Production
|
||||
env_file:
|
||||
- env.d/common
|
||||
- env.d/backend
|
||||
- env.d/yprovider
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
- env.d/backend
|
||||
- env.d/yprovider
|
||||
- env.d/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "manage.py", "check"]
|
||||
interval: 15s
|
||||
@@ -45,24 +45,24 @@ services:
|
||||
image: lasuite/impress-y-provider:latest
|
||||
user: ${DOCKER_USER:-1000}
|
||||
env_file:
|
||||
- env.d/common
|
||||
- env.d/yprovider
|
||||
- env.d/common
|
||||
- env.d/yprovider
|
||||
|
||||
frontend:
|
||||
image: lasuite/impress-frontend:latest
|
||||
user: "101"
|
||||
entrypoint:
|
||||
- /docker-entrypoint.sh
|
||||
- /docker-entrypoint.sh
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
env_file:
|
||||
- env.d/common
|
||||
- env.d/common
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
#environment:
|
||||
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||
volumes:
|
||||
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
```bash
|
||||
mkdir keycloak
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||
```
|
||||
|
||||
### Step 2:. Update `env.d/` files
|
||||
|
||||
@@ -7,23 +7,23 @@ services:
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/kc_postgresql
|
||||
- env.d/kc_postgresql
|
||||
volumes:
|
||||
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1.3
|
||||
command: ["start"]
|
||||
env_file:
|
||||
- env.d/kc_postgresql
|
||||
- env.d/keycloak
|
||||
- env.d/kc_postgresql
|
||||
- env.d/keycloak
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||
depends_on:
|
||||
kc_postgresql::
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
# Uncomment if using our nginx proxy example
|
||||
@@ -33,4 +33,4 @@ services:
|
||||
#
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
# external: true
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
```bash
|
||||
mkdir minio
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
|
||||
curl -o minio/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
|
||||
```
|
||||
|
||||
### Step 2:. Update compose file with your own values
|
||||
|
||||
@@ -2,8 +2,8 @@ services:
|
||||
minio:
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=<set minio root username>
|
||||
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||
- MINIO_ROOT_USER=<set minio root username>
|
||||
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||
@@ -16,12 +16,12 @@ services:
|
||||
entrypoint: ""
|
||||
command: minio server /data
|
||||
volumes:
|
||||
- ./data/minio:/data
|
||||
- ./data/minio:/data
|
||||
# Uncomment if using our nginx proxy example
|
||||
# networks:
|
||||
# - proxy-tier
|
||||
# - proxy-tier
|
||||
|
||||
# Uncomment if using our nginx proxy example
|
||||
#networks:
|
||||
# proxy-tier:
|
||||
# external: true
|
||||
# external: true
|
||||
|
||||
@@ -13,7 +13,7 @@ Acme-companion is a lightweight companion container for nginx-proxy. It handles
|
||||
|
||||
```bash
|
||||
mkdir nginx-proxy
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||
curl -o nginx-proxy/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||
```
|
||||
|
||||
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||
|
||||
@@ -3,28 +3,28 @@ services:
|
||||
image: nginxproxy/nginx-proxy
|
||||
container_name: nginx-proxy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- html:/usr/share/nginx/html
|
||||
- certs:/etc/nginx/certs:ro
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- html:/usr/share/nginx/html
|
||||
- certs:/etc/nginx/certs:ro
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
networks:
|
||||
- proxy-tier
|
||||
- proxy-tier
|
||||
|
||||
acme-companion:
|
||||
image: nginxproxy/acme-companion
|
||||
container_name: nginx-proxy-acme
|
||||
environment:
|
||||
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||
volumes_from:
|
||||
- nginx-proxy
|
||||
- nginx-proxy
|
||||
volumes:
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- acme:/etc/acme.sh
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- acme:/etc/acme.sh
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- proxy-tier
|
||||
- proxy-tier
|
||||
|
||||
networks:
|
||||
proxy-tier:
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
djangoSecretKey: &djangoSecretKey "lkjsdlfkjsldkfjslkdfjslkdjfslkdjf"
|
||||
djangoSuperUserEmail: admin@example.com
|
||||
djangoSuperUserPass: admin
|
||||
aiApiKey: changeme
|
||||
aiBaseUrl: changeme
|
||||
oidc:
|
||||
clientId: impress
|
||||
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
image:
|
||||
repository: lasuite/impress-backend
|
||||
pullPolicy: Always
|
||||
@@ -6,89 +15,98 @@ image:
|
||||
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_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Feature
|
||||
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
|
||||
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
|
||||
DJANGO_SECRET_KEY: AgoodOrAbadKey
|
||||
DJANGO_SECRET_KEY: *djangoSecretKey
|
||||
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_LOGO_IMG: https://docs.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_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/logout
|
||||
OIDC_RP_CLIENT_ID: docs
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_VERIFY_SSL: False
|
||||
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: "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
|
||||
LOGIN_REDIRECT_URL: https://docs.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://docs.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://docs.127.0.0.1.nip.io
|
||||
DB_HOST: postgresql-dev-backend-postgres
|
||||
DB_NAME:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: database
|
||||
DB_USER:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: username
|
||||
DB_PASSWORD:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: password
|
||||
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
|
||||
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
|
||||
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
|
||||
AWS_S3_ENDPOINT_URL: http://minio-dev-backend-minio.impress.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: dinum
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: impress-media-storage
|
||||
AWS_STORAGE_BUCKET_NAME: docs-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
|
||||
|
||||
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
|
||||
migrate:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/impress.py"
|
||||
- "impress.wsgi:application"
|
||||
- "--reload"
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input
|
||||
restartPolicy: Never
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Database is ready"
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
mountPath: /cert/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
@@ -97,12 +115,7 @@ backend:
|
||||
- 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
|
||||
@@ -117,60 +130,47 @@ yProvider:
|
||||
tag: "latest"
|
||||
|
||||
envVars:
|
||||
COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io
|
||||
COLLABORATION_LOGGING: true
|
||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_SECRET: my-secret
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
|
||||
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
|
||||
|
||||
# Mount the certificate so yProvider can establish tls with the backend
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/share/ca-certificates/cacert.pem
|
||||
subPath: cacert.pem
|
||||
ingress:
|
||||
enabled: true
|
||||
host: docs.127.0.0.1.nip.io
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 100m
|
||||
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
ingressCollaborationWS:
|
||||
enabled: true
|
||||
host: docs.127.0.0.1.nip.io
|
||||
|
||||
ingressCollaborationApi:
|
||||
enabled: true
|
||||
host: docs.127.0.0.1.nip.io
|
||||
|
||||
ingressAdmin:
|
||||
enabled: true
|
||||
host: docs.127.0.0.1.nip.io
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
host: docs.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-url: https://docs.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
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio-dev-backend-minio.impress.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /docs-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.impress.svc.cluster.local
|
||||
host: minio-dev-backend-minio.impress.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,24 @@
|
||||
auth:
|
||||
rootUser: root
|
||||
rootPassword: password
|
||||
provisioning:
|
||||
minio:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: impress-media-storage
|
||||
versioning: true
|
||||
image: minio/minio
|
||||
name: minio
|
||||
# serviceNameOverride: docs-minio
|
||||
ingress:
|
||||
enabled: true
|
||||
hostname: docs-minio.127.0.0.1.nip.io
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: docs-tls
|
||||
consoleIngress:
|
||||
enabled: true
|
||||
hostname: docs-minio-console.127.0.0.1.nip.io
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: docs-tls
|
||||
api:
|
||||
port: 80
|
||||
username: dinum
|
||||
password: password
|
||||
bucket: docs-media-storage
|
||||
versioning: true
|
||||
size: 1Gi
|
||||
@@ -1,7 +1,9 @@
|
||||
auth:
|
||||
postgres:
|
||||
enabled: true
|
||||
name: postgres
|
||||
#serviceNameOverride: postgres
|
||||
image: postgres:16-alpine
|
||||
username: dinum
|
||||
password: pass
|
||||
database: impress
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
database: dinum
|
||||
size: 1Gi
|
||||
@@ -1,4 +1,7 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
name: redis
|
||||
#serviceNameOverride: redis
|
||||
image: redis:8.2-alpine
|
||||
username: user
|
||||
password: pass
|
||||
32
docs/installation/README.md
Normal file
32
docs/installation/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Installation
|
||||
If you want to install Docs you've come to the right place.
|
||||
Here are a bunch of resources to help you install the project.
|
||||
|
||||
## Kubernetes
|
||||
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
|
||||
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
|
||||
|
||||
## Docker Compose
|
||||
We are aware that not everyone has Kubernetes Cluster laying around 😆.
|
||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
|
||||
Please follow the instructions [here](/docs/installation/compose.md).
|
||||
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
|
||||
|
||||
## Other ways to install Docs
|
||||
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
|
||||
|
||||
Here is the list of other methods in alphabetical order:
|
||||
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
|
||||
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
|
||||
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
|
||||
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
|
||||
|
||||
Feel free to make a PR to add ones that are not listed above 🙏
|
||||
|
||||
## Cloud providers
|
||||
Some cloud providers are making it easy to deploy Docs on their infrastructure.
|
||||
|
||||
Here is the list in alphabetical order:
|
||||
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
|
||||
|
||||
Feel free to make a PR to add ones that are not listed above 🙏
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/k8s.md)
|
||||
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -31,11 +31,17 @@ For older versions of Docker Engine that do not include Docker Compose:
|
||||
|
||||
```bash
|
||||
mkdir -p docs/env.d
|
||||
cd docs
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
|
||||
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
|
||||
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
|
||||
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
|
||||
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
|
||||
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
|
||||
```
|
||||
|
||||
If you are using the sample nginx-proxy configuration:
|
||||
```bash
|
||||
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
|
||||
```
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Docs on a k8
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we provide an example)
|
||||
- a Memcached server (if you don't have one, we provide an example)
|
||||
- a Redis server (if you don't have one, we provide an example)
|
||||
- a S3 bucket (if you don't have one, we provide an example)
|
||||
|
||||
### Test cluster
|
||||
@@ -100,50 +100,66 @@ When your k8s cluster is ready (the ingress nginx controller is up), you can sta
|
||||
|
||||
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.
|
||||
|
||||
The namespace `impress` is already created, you can work in it and configure your kubectl cli to use it by default.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=impress
|
||||
```
|
||||
|
||||
## Preparation
|
||||
|
||||
We provide our own helm chart for all development dependencies, it is available here https://github.com/suitenumerique/helm-dev-backend
|
||||
This provided chart is for development purpose only and is not ready to use in production.
|
||||
|
||||
You can install it on your cluster to deploy keycloak, minio, postgresql and redis.
|
||||
|
||||
### What do 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
|
||||
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/keycloak.values.yaml keycloak dev-backend
|
||||
$ #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
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-dev-backend-keycloak-0 1/1 Running 0 20s
|
||||
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 20s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
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_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||
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**
|
||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connection values
|
||||
|
||||
Docs needs a redis so we start by deploying one:
|
||||
|
||||
```
|
||||
$ 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
|
||||
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/redis.values.yaml redis dev-backend
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-dev-backend-keycloak-0 1/1 Running 0 113s
|
||||
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 113s
|
||||
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 2s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
|
||||
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
|
||||
```
|
||||
|
||||
### Find postgresql connection values
|
||||
@@ -151,26 +167,33 @@ redis-master-0 1/1 Running 0 35s
|
||||
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, 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
|
||||
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/postgresql.values.yaml postgresql dev-backend
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-dev-backend-keycloak-0 1/1 Running 0 3m42s
|
||||
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 3m42s
|
||||
postgresql-dev-backend-postgres-0 1/1 Running 0 13s
|
||||
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 111s
|
||||
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_HOST: postgresql-dev-backend-postgres
|
||||
DB_NAME:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: database
|
||||
DB_USER:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: username
|
||||
DB_PASSWORD:
|
||||
secretKeyRef:
|
||||
name: postgresql-dev-backend-postgres
|
||||
key: password
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
### Find s3 bucket connection values
|
||||
@@ -178,15 +201,15 @@ POSTGRES_PASSWORD: pass
|
||||
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, 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
|
||||
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/minio.values.yaml minio dev-backend
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-dev-backend-keycloak-0 1/1 Running 0 6m12s
|
||||
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 6m12s
|
||||
minio-dev-backend-minio-0 1/1 Running 0 10s
|
||||
postgresql-dev-backend-postgres-0 1/1 Running 0 2m43s
|
||||
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 4m21s
|
||||
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -196,20 +219,18 @@ Now you are ready to deploy Docs without AI. AI requires more dependencies (Open
|
||||
```
|
||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||
$ helm repo update
|
||||
$ helm install impress impress/docs -f examples/impress.values.yaml
|
||||
$ helm install impress impress/docs -f docs/examples/helm/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
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
impress-docs-backend-8494fb797d-8k8wt 1/1 Running 0 6m45s
|
||||
impress-docs-celery-worker-764b5dd98f-9qd6v 1/1 Running 0 6m45s
|
||||
impress-docs-frontend-5b69b65cc4-s8pps 1/1 Running 0 6m45s
|
||||
impress-docs-y-provider-5fc7ccd8cc-6ttrf 1/1 Running 0 6m45s
|
||||
keycloak-dev-backend-keycloak-0 1/1 Running 0 24m
|
||||
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 24m
|
||||
minio-dev-backend-minio-0 1/1 Running 0 8m24s
|
||||
postgresql-dev-backend-postgres-0 1/1 Running 0 20m
|
||||
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 22m
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
@@ -218,13 +239,15 @@ In order to test your deployment you have to log into your instance. If you excl
|
||||
|
||||
```
|
||||
$ 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
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
impress-docs <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
|
||||
impress-docs-admin <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
|
||||
impress-docs-collaboration-api <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
|
||||
impress-docs-media <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
|
||||
impress-docs-ws <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
|
||||
keycloak-dev-backend-keycloak <none> docs-keycloak.127.0.0.1.nip.io localhost 80, 443 24m
|
||||
minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io localhost 80, 443 8m48s
|
||||
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
|
||||
```
|
||||
|
||||
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs.
|
||||
|
||||
41
docs/search.md
Normal file
41
docs/search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Setup the Find search for Impress
|
||||
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
Configure a **Service** for Docs application with these settings
|
||||
|
||||
- **Name**: `docs`<br>_request.auth.name of the Docs application._
|
||||
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
|
||||
|
||||
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
|
||||
# The token from service "docs" of Find application (development).
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
# Maximum number of results expected from the search endpoint
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
```
|
||||
|
||||
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
|
||||
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
@@ -97,6 +97,17 @@ Production deployments differ significantly from development environments. The t
|
||||
| 5433 | PostgreSQL (Keycloak) |
|
||||
| 1081 | MailCatcher |
|
||||
|
||||
**With fulltext search service**
|
||||
|
||||
| Port | Service |
|
||||
| --------- | --------------------- |
|
||||
| 8081 | Find (Django) |
|
||||
| 9200 | Opensearch |
|
||||
| 9600 | Opensearch admin |
|
||||
| 5601 | Opensearch dashboard |
|
||||
| 25432 | PostgreSQL (Find) |
|
||||
|
||||
|
||||
## 6. Sizing Guidelines
|
||||
|
||||
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||
|
||||
@@ -8,7 +8,7 @@ To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to t
|
||||
FRONTEND_CSS_URL=http://anything/custom-style.css
|
||||
```
|
||||
|
||||
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
|
||||
|
||||
### Benefits
|
||||
|
||||
@@ -32,6 +32,79 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
||||
|
||||
----
|
||||
|
||||
# Runtime JavaScript Injection 🚀
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. For example:
|
||||
|
||||
```javascript
|
||||
FRONTEND_JS_URL=http://anything/custom-script.js
|
||||
```
|
||||
|
||||
Once you've set this variable, Docs will load your custom JavaScript file and execute it in the browser, allowing you to modify the application's behavior at runtime.
|
||||
|
||||
### Benefits
|
||||
|
||||
This feature provides several benefits, including:
|
||||
|
||||
* **Dynamic customization** 🔄: With this feature, you can dynamically modify the behavior and appearance of our application without requiring any code changes.
|
||||
* **Flexibility** 🌈: You can add custom functionality, modify existing features, or integrate third-party services.
|
||||
* **Runtime injection** ⏱️: This feature allows you to inject JavaScript into the application at runtime, without requiring a restart or recompilation.
|
||||
|
||||
### Example Use Case
|
||||
|
||||
Let's say you want to add a custom menu to the application header. You can create a custom JavaScript file with the following contents:
|
||||
|
||||
```javascript
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function initCustomMenu() {
|
||||
// Wait for the page to be fully loaded
|
||||
const header = document.querySelector('header');
|
||||
if (!header) return false;
|
||||
|
||||
// Create and inject your custom menu
|
||||
const customMenu = document.createElement('div');
|
||||
customMenu.innerHTML = '<button>Custom Menu</button>';
|
||||
header.appendChild(customMenu);
|
||||
|
||||
console.log('Custom menu added successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCustomMenu);
|
||||
} else {
|
||||
initCustomMenu();
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header.
|
||||
|
||||
----
|
||||
|
||||
# **Your Docs icon** 📝
|
||||
|
||||
You can add your own Docs icon in the header from the theme customization file.
|
||||
|
||||
### Settings 🔧
|
||||
|
||||
```shellscript
|
||||
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
```
|
||||
|
||||
### Example of JSON
|
||||
|
||||
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
This configuration is optional. If not set, the default icon will be used.
|
||||
|
||||
----
|
||||
|
||||
# **Footer Configuration** 📝
|
||||
|
||||
The footer is configurable from the theme customization file.
|
||||
|
||||
@@ -83,55 +83,6 @@ If you already have CRLF line endings in your local repository, the **best appro
|
||||
git commit -m "✏️(project) Fix line endings to LF"
|
||||
```
|
||||
|
||||
## Minio Permission Issues on Windows
|
||||
|
||||
### Problem Description
|
||||
|
||||
On Windows, you may encounter permission-related errors when running Minio in development mode with Docker Compose. This typically happens because:
|
||||
|
||||
- **Windows file permissions** don't map well to Unix-style user IDs used in Docker containers
|
||||
- **Docker Desktop** may have issues with user mapping when using the `DOCKER_USER` environment variable
|
||||
- **Minio container** fails to start or access volumes due to permission conflicts
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
- Minio container fails to start with permission denied errors
|
||||
- Error messages related to file system permissions in Minio logs
|
||||
- Unable to create or access buckets in the development environment
|
||||
- Docker Compose showing Minio service as unhealthy or exited
|
||||
|
||||
### Solution for Windows Users
|
||||
|
||||
If you encounter Minio permission issues on Windows, you can temporarily disable user mapping for the Minio service:
|
||||
|
||||
1. **Open the `compose.yml` file**
|
||||
|
||||
2. **Comment out the user directive** in the `minio` service section:
|
||||
```yaml
|
||||
minio:
|
||||
# user: ${DOCKER_USER:-1000} # Comment this line on Windows if permission issues occur
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=impress
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
# ... rest of the configuration
|
||||
```
|
||||
|
||||
3. **Restart the services**:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- Commenting out the `user` directive allows the Minio container to run with its default user
|
||||
- This bypasses Windows-specific permission mapping issues
|
||||
- The container will have the necessary permissions to access and manage the mounted volumes
|
||||
|
||||
### Note
|
||||
|
||||
This is a **development-only workaround**. In production environments, proper user mapping and security considerations should be maintained according to your deployment requirements.
|
||||
|
||||
## Frontend File Watching Issues on Windows
|
||||
|
||||
### Problem Description
|
||||
|
||||
@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
|
||||
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
@@ -66,3 +75,12 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
@@ -3,3 +3,7 @@ BURST_THROTTLE_RATES="200/minute"
|
||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
# Throttle
|
||||
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
@@ -43,8 +43,8 @@ OIDC_RP_CLIENT_ID=<client_id>
|
||||
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
#USER_OIDC_FIELD_TO_SHORTNAME
|
||||
#USER_OIDC_FIELDS_TO_FULLNAME
|
||||
#OIDC_USERINFO_SHORTNAME_FIELD
|
||||
#OIDC_USERINFO_FULLNAME_FIELDS
|
||||
|
||||
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
||||
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api
|
||||
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
|
||||
Y_PROVIDER_API_KEY=<generate a random key>
|
||||
COLLABORATION_SERVER_SECRET=<generate a random key>
|
||||
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
publiccodeYmlVersion: "2.4.0"
|
||||
publiccodeYmlVersion: "0.5.0"
|
||||
name: Docs
|
||||
url: https://github.com/suitenumerique/docs
|
||||
landingURL: https://github.com/suitenumerique/docs
|
||||
creationDate: 2023-12-10
|
||||
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||
usedBy:
|
||||
- Direction interministériel du numérique (DINUM)
|
||||
- Direction interministérielle du numérique (DINUM)
|
||||
fundedBy:
|
||||
- name: Direction interministériel du numérique (DINUM)
|
||||
url: https://www.numerique.gouv.fr
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
|
||||
softwareType: "standalone/other"
|
||||
platforms:
|
||||
- "web"
|
||||
developmentStatus: "stable"
|
||||
description:
|
||||
en:
|
||||
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
|
||||
@@ -18,10 +20,18 @@ description:
|
||||
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
|
||||
legal:
|
||||
license: MIT
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- de
|
||||
- en
|
||||
- es
|
||||
- fr
|
||||
- nl
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Virgile Deville"
|
||||
email: "virgile.deville@numerique.gouv.fr"
|
||||
- name: "samuel.paccoud"
|
||||
- name: "Samuel Paccoud"
|
||||
email: "samuel.paccoud@numerique.gouv.fr"
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||
"schedule": ["before 7am on monday"],
|
||||
"prCreation": "not-pending",
|
||||
"rebaseWhen": "conflicted",
|
||||
"updateNotScheduled": false,
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
@@ -15,16 +19,22 @@
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@hocuspocus/provider",
|
||||
"@hocuspocus/server",
|
||||
"@next/eslint-plugin-next",
|
||||
"docx",
|
||||
"eslint",
|
||||
"eslint-config-next",
|
||||
"fetch-mock",
|
||||
"next",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"workbox-webpack-plugin"
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
|
||||
from . import models
|
||||
|
||||
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
form = movenodeform_factory(models.Document)
|
||||
inlines = (DocumentAccessInline,)
|
||||
list_display = (
|
||||
"id",
|
||||
|
||||
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_masked = django_filters.BooleanFilter(
|
||||
method="filter_is_masked", label=_("Masked")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
@@ -106,3 +109,30 @@ class ListDocumentFilter(DocumentFilter):
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are masked by the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_masked=true
|
||||
→ Filters documents marked as masked by the logged-in user
|
||||
- /api/v1.0/documents/?is_masked=false
|
||||
→ Filters documents not marked as masked by the logged-in user
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset_method = queryset.filter if bool(value) else queryset.exclude
|
||||
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
|
||||
|
||||
|
||||
class UserSearchFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for searching users.
|
||||
"""
|
||||
|
||||
q = django_filters.CharFilter(min_length=5, max_length=254)
|
||||
|
||||
@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
|
||||
|
||||
action = view.action
|
||||
return abilities.get(action, False)
|
||||
|
||||
|
||||
class CommentPermission(permissions.BasePermission):
|
||||
"""Permission class for comments."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission for a given object."""
|
||||
if view.action in ["create", "list"]:
|
||||
document_abilities = view.get_document_or_404().get_abilities(request.user)
|
||||
return document_abilities["comment"]
|
||||
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Client serializers for the impress core app."""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import binascii
|
||||
import mimetypes
|
||||
@@ -7,12 +8,13 @@ from base64 import b64decode
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import magic
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import choices, enums, models, utils
|
||||
from core import choices, enums, models, utils, validators
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
@@ -23,11 +25,30 @@ from core.services.converter_services import (
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
full_name = serializers.SerializerMethodField(read_only=True)
|
||||
short_name = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
if not instance.full_name:
|
||||
email = instance.email.split("@")[0]
|
||||
return slugify(email)
|
||||
|
||||
return instance.full_name
|
||||
|
||||
def get_short_name(self, instance):
|
||||
"""Return the short name of the user."""
|
||||
if not instance.short_name:
|
||||
email = instance.email.split("@")[0]
|
||||
return slugify(email)
|
||||
|
||||
return instance.short_name
|
||||
|
||||
|
||||
class UserLightSerializer(UserSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
@@ -70,6 +91,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_role = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
@@ -82,6 +104,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -104,6 +127,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -145,6 +169,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
request = self.context.get("request")
|
||||
return instance.get_role(request.user) if request else None
|
||||
|
||||
def get_deleted_at(self, instance):
|
||||
"""Return the deleted_at of the current document."""
|
||||
return instance.ancestors_deleted_at
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
@@ -173,6 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -196,6 +225,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
@@ -402,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
content = serializers.CharField(required=True)
|
||||
# User
|
||||
sub = serializers.CharField(
|
||||
required=True, validators=[models.User.sub_validator], max_length=255
|
||||
required=True, validators=[validators.sub_validator], max_length=255
|
||||
)
|
||||
email = serializers.EmailField(required=True)
|
||||
language = serializers.ChoiceField(
|
||||
@@ -486,6 +516,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
"""
|
||||
|
||||
link_reach = serializers.ChoiceField(
|
||||
choices=models.LinkReachChoices.choices, required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
@@ -493,6 +527,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"link_reach",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that link_role and link_reach are compatible using get_select_options."""
|
||||
link_reach = attrs.get("link_reach")
|
||||
link_role = attrs.get("link_role")
|
||||
|
||||
if not link_reach:
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": _("This field is required.")}
|
||||
)
|
||||
|
||||
# Get available options based on ancestors' link definition
|
||||
available_options = models.LinkReachChoices.get_select_options(
|
||||
**self.instance.ancestors_link_definition
|
||||
)
|
||||
|
||||
# Validate link_reach is allowed
|
||||
if link_reach not in available_options:
|
||||
msg = _(
|
||||
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": msg % {"link_reach": link_reach}}
|
||||
)
|
||||
|
||||
# Validate link_role is compatible with link_reach
|
||||
allowed_roles = available_options[link_reach]
|
||||
|
||||
# Restricted reach: link_role must be None
|
||||
if link_reach == models.LinkReachChoices.RESTRICTED:
|
||||
if link_role is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
"Cannot set link_role when link_reach is 'restricted'. "
|
||||
"Link role must be null for restricted reach."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
# Non-restricted: link_role must be in allowed roles
|
||||
if link_role not in allowed_roles:
|
||||
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
|
||||
f"Allowed roles: {allowed_roles_str}"
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -664,6 +750,9 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
if self.instance is None:
|
||||
attrs["issuer"] = user
|
||||
|
||||
if attrs.get("email"):
|
||||
attrs["email"] = attrs["email"].lower()
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_role(self, role):
|
||||
@@ -698,7 +787,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for creating a document ask for access."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=models.RoleChoices.choices,
|
||||
choices=[
|
||||
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
|
||||
],
|
||||
required=False,
|
||||
default=models.RoleChoices.READER,
|
||||
)
|
||||
@@ -722,11 +813,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
||||
|
||||
def get_abilities(self, invitation) -> dict:
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return invitation.get_abilities(request.user)
|
||||
return instance.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -801,3 +892,134 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
choices=enums.MoveNodePositionChoices.choices,
|
||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
)
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
"""Serialize reactions."""
|
||||
|
||||
users = UserLightSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
fields = [
|
||||
"id",
|
||||
"emoji",
|
||||
"created_at",
|
||||
"users",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "users"]
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize comments (nested under a thread) with reactions and abilities."""
|
||||
|
||||
user = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField()
|
||||
reactions = ReactionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"body",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reactions",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reactions",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate comment data."""
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
attrs["thread_id"] = self.context["thread_id"]
|
||||
attrs["user_id"] = user.id if user else None
|
||||
return attrs
|
||||
|
||||
def get_abilities(self, obj):
|
||||
"""Return comment's abilities."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return obj.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class ThreadSerializer(serializers.ModelSerializer):
|
||||
"""Serialize threads in a backward compatible shape for current frontend.
|
||||
|
||||
We expose a flatten representation where ``content`` maps to the first
|
||||
comment's body. Creating a thread requires a ``content`` field which is
|
||||
stored as the first comment.
|
||||
"""
|
||||
|
||||
creator = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
body = serializers.JSONField(write_only=True, required=True)
|
||||
comments = serializers.SerializerMethodField(read_only=True)
|
||||
comments = CommentSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Thread
|
||||
fields = [
|
||||
"id",
|
||||
"body",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"abilities",
|
||||
"comments",
|
||||
"resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"metadata",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"abilities",
|
||||
"comments",
|
||||
"resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"metadata",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate thread data."""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
attrs["document_id"] = self.context["resource_id"]
|
||||
attrs["creator_id"] = user.id if user else None
|
||||
|
||||
return attrs
|
||||
|
||||
def get_abilities(self, thread):
|
||||
"""Return thread's abilities."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return thread.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
51
src/backend/core/api/throttling.py
Normal file
51
src/backend/core/api/throttling.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredScopedRateThrottle
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
capture_message(message, "warning")
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class DocumentThrottle(MonitoredScopedRateThrottle):
|
||||
"""
|
||||
Throttle for document-related endpoints, with an exception for requests from the
|
||||
collaboration server.
|
||||
"""
|
||||
|
||||
scope = "document"
|
||||
|
||||
def allow_request(self, request, view):
|
||||
"""
|
||||
Override to skip throttling for requests from the collaboration server.
|
||||
|
||||
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
|
||||
Using a custom header instead of Authorization to avoid triggering
|
||||
authentication middleware.
|
||||
"""
|
||||
|
||||
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
|
||||
|
||||
# Check if this is a valid y-provider request and exempt from throttling
|
||||
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
|
||||
if y_provider_key and y_provider_header == y_provider_key:
|
||||
return True
|
||||
|
||||
return super().allow_request(request, view)
|
||||
@@ -1,6 +1,8 @@
|
||||
"""API endpoints"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -13,12 +15,15 @@ from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import connection, transaction
|
||||
from django.db import models as db
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.db.models.functions import Greatest, Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -29,19 +34,38 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.api.filters import remove_accents
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.converter_services import (
|
||||
ServiceUnavailableError as YProviderServiceUnavailableError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
ValidationError as YProviderValidationError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
UserListThrottleSustained,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,18 +159,6 @@ class Pagination(drf.pagination.PageNumberPagination):
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
):
|
||||
@@ -177,18 +189,26 @@ class UserViewSet(
|
||||
if self.action != "list":
|
||||
return queryset
|
||||
|
||||
filterset = UserSearchFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.query_params.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
return queryset.none()
|
||||
filter_data = filterset.form.cleaned_data
|
||||
query = remove_accents(filter_data["q"])
|
||||
|
||||
# For emails, match emails by Levenstein distance to prevent typing errors
|
||||
if "@" in query:
|
||||
return (
|
||||
queryset.annotate(
|
||||
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
|
||||
distance=RawSQL(
|
||||
"levenshtein(unaccent(email::text), %s::text)", (query,)
|
||||
)
|
||||
)
|
||||
.filter(distance__lte=3)
|
||||
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
@@ -197,11 +217,15 @@ class UserViewSet(
|
||||
# Use trigram similarity for non-email-like queries
|
||||
# For performance reasons we filter first by similarity, which relies on an
|
||||
# index, then only calculate precise similarity scores for sorting purposes
|
||||
|
||||
return (
|
||||
queryset.filter(email__trigram_word_similar=query)
|
||||
.annotate(similarity=TrigramSimilarity("email", query))
|
||||
queryset.annotate(
|
||||
sim_email=TrigramSimilarity("email", query),
|
||||
sim_name=TrigramSimilarity("full_name", query),
|
||||
)
|
||||
.annotate(similarity=Greatest("sim_email", "sim_name"))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -359,7 +383,9 @@ class DocumentViewSet(
|
||||
permission_classes = [
|
||||
permissions.DocumentPermission,
|
||||
]
|
||||
queryset = models.Document.objects.all()
|
||||
throttle_classes = [DocumentThrottle]
|
||||
throttle_scope = "document"
|
||||
queryset = models.Document.objects.select_related("creator").all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
@@ -367,6 +393,7 @@ class DocumentViewSet(
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
search_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset performing all annotation and filtering on the document tree structure."""
|
||||
@@ -455,9 +482,8 @@ class DocumentViewSet(
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
queryset = filterset.filters["is_favorite"].filter(
|
||||
queryset, filter_data["is_favorite"]
|
||||
)
|
||||
for field in ["is_favorite", "is_masked"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
@@ -618,12 +644,32 @@ class DocumentViewSet(
|
||||
The selected documents are those deleted within the cutoff period defined in the
|
||||
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
access_documents_paths = (
|
||||
models.DocumentAccess.objects.select_related("document")
|
||||
.filter(
|
||||
db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams),
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
.values_list("document__path", flat=True)
|
||||
)
|
||||
|
||||
if not access_documents_paths:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
children_clause = db.Q()
|
||||
for path in access_documents_paths:
|
||||
children_clause |= db.Q(path__startswith=path)
|
||||
|
||||
queryset = self.queryset.filter(
|
||||
children_clause,
|
||||
deleted_at__isnull=False,
|
||||
deleted_at__gte=models.get_trashbin_cutoff(),
|
||||
)
|
||||
queryset = queryset.annotate_user_roles(self.request.user)
|
||||
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@@ -787,7 +833,11 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# GET: List children
|
||||
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = (
|
||||
document.get_children()
|
||||
.select_related("creator")
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
@@ -841,26 +891,48 @@ class DocumentViewSet(
|
||||
user = self.request.user
|
||||
|
||||
try:
|
||||
current_document = self.queryset.only("depth", "path").get(pk=pk)
|
||||
current_document = (
|
||||
self.queryset.select_related(None)
|
||||
.only("depth", "path", "ancestors_deleted_at")
|
||||
.get(pk=pk)
|
||||
)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise drf.exceptions.NotFound() from excpt
|
||||
|
||||
ancestors = (
|
||||
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
is_deleted = current_document.ancestors_deleted_at is not None
|
||||
|
||||
# Get the highest readable ancestor
|
||||
highest_readable = (
|
||||
ancestors.readable_per_se(request.user).only("depth", "path").first()
|
||||
)
|
||||
if highest_readable is None:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
if is_deleted:
|
||||
if current_document.get_role(user) != models.RoleChoices.OWNER:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
)
|
||||
highest_readable = current_document
|
||||
ancestors = self.queryset.select_related(None).filter(pk=pk)
|
||||
else:
|
||||
ancestors = (
|
||||
(
|
||||
current_document.get_ancestors()
|
||||
| self.queryset.select_related(None).filter(pk=pk)
|
||||
)
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
# Get the highest readable ancestor
|
||||
highest_readable = (
|
||||
ancestors.select_related(None)
|
||||
.readable_per_se(request.user)
|
||||
.only("depth", "path")
|
||||
.first()
|
||||
)
|
||||
|
||||
if highest_readable is None:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
)
|
||||
paths_links_mapping = {}
|
||||
ancestors_links = []
|
||||
children_clause = db.Q()
|
||||
@@ -881,7 +953,12 @@ class DocumentViewSet(
|
||||
|
||||
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
|
||||
|
||||
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
|
||||
queryset = (
|
||||
ancestors.select_related("creator").filter(
|
||||
depth__gte=highest_readable.depth
|
||||
)
|
||||
| children
|
||||
)
|
||||
queryset = queryset.order_by("path")
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
@@ -919,37 +996,64 @@ class DocumentViewSet(
|
||||
in the payload.
|
||||
"""
|
||||
# Get document while checking permissions
|
||||
document = self.get_object()
|
||||
document_to_duplicate = self.get_object()
|
||||
|
||||
serializer = serializers.DocumentDuplicationSerializer(
|
||||
data=request.data, partial=True
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with_accesses = serializer.validated_data.get("with_accesses", False)
|
||||
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
|
||||
user_role = document_to_duplicate.get_role(request.user)
|
||||
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
|
||||
|
||||
base64_yjs_content = document.content
|
||||
base64_yjs_content = document_to_duplicate.content
|
||||
|
||||
# Duplicate the document instance
|
||||
link_kwargs = (
|
||||
{"link_reach": document.link_reach, "link_role": document.link_role}
|
||||
{
|
||||
"link_reach": document_to_duplicate.link_reach,
|
||||
"link_role": document_to_duplicate.link_role,
|
||||
}
|
||||
if with_accesses
|
||||
else {}
|
||||
)
|
||||
extracted_attachments = set(extract_attachments(document.content))
|
||||
attachments = list(extracted_attachments & set(document.attachments))
|
||||
duplicated_document = document.add_sibling(
|
||||
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
|
||||
attachments = list(
|
||||
extracted_attachments & set(document_to_duplicate.attachments)
|
||||
)
|
||||
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
|
||||
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
|
||||
user_role
|
||||
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
|
||||
duplicated_document = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
duplicated_from=document_to_duplicate,
|
||||
**link_kwargs,
|
||||
)
|
||||
models.DocumentAccess.objects.create(
|
||||
document=duplicated_document,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
duplicated_document = document_to_duplicate.add_sibling(
|
||||
"right",
|
||||
title=capfirst(_("copy of {title}").format(title=document.title)),
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
duplicated_from=document,
|
||||
duplicated_from=document_to_duplicate,
|
||||
creator=request.user,
|
||||
**link_kwargs,
|
||||
)
|
||||
|
||||
# Always add the logged-in user as OWNER for root documents
|
||||
if document.is_root():
|
||||
if document_to_duplicate.is_root():
|
||||
accesses_to_create = [
|
||||
models.DocumentAccess(
|
||||
document=duplicated_document,
|
||||
@@ -961,7 +1065,7 @@ class DocumentViewSet(
|
||||
# If accesses should be duplicated, add other users' accesses as per original document
|
||||
if with_accesses and is_owner_or_admin:
|
||||
original_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document
|
||||
document=document_to_duplicate
|
||||
).exclude(user=request.user)
|
||||
|
||||
accesses_to_create.extend(
|
||||
@@ -981,6 +1085,83 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_INDEXER_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -1022,7 +1203,7 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-z-]+)",
|
||||
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def versions_detail(self, request, pk, version_id, *args, **kwargs):
|
||||
@@ -1109,15 +1290,50 @@ class DocumentViewSet(
|
||||
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(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", "delete"], url_path="mask")
|
||||
def mask(self, request, *args, **kwargs):
|
||||
"""Mask or unmask the document for the logged-in user based on the HTTP method."""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
|
||||
try:
|
||||
link_trace = models.LinkTrace.objects.get(document=document, user=user)
|
||||
except models.LinkTrace.DoesNotExist:
|
||||
return drf.response.Response(
|
||||
{"detail": "User never accessed this document before."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
if link_trace.is_masked:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already masked"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
link_trace.is_masked = True
|
||||
link_trace.save(update_fields=["is_masked"])
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was masked"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# Handle DELETE method to unmask document
|
||||
if not link_trace.is_masked:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already not masked"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
link_trace.is_masked = False
|
||||
link_trace.save(update_fields=["is_masked"])
|
||||
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@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"""
|
||||
@@ -1248,7 +1464,8 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
attachments_documents = (
|
||||
self.queryset.filter(attachments__contains=[key])
|
||||
self.queryset.select_related(None)
|
||||
.filter(attachments__contains=[key])
|
||||
.only("path")
|
||||
.order_by("path")
|
||||
)
|
||||
@@ -1407,6 +1624,15 @@ class DocumentViewSet(
|
||||
|
||||
url = unquote(url)
|
||||
|
||||
url_validator = URLValidator(schemes=["http", "https"])
|
||||
try:
|
||||
url_validator(url)
|
||||
except drf.exceptions.ValidationError as e:
|
||||
return drf.response.Response(
|
||||
{"detail": str(e)},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
@@ -1437,12 +1663,75 @@ class DocumentViewSet(
|
||||
return proxy_response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error("Proxy request failed: %s", str(e))
|
||||
return drf_response.Response(
|
||||
{"error": f"Failed to fetch resource: {e!s}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
logger.exception(e)
|
||||
return drf.response.Response(
|
||||
{"error": f"Failed to fetch resource from {url}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="content",
|
||||
name="Get document content in different formats",
|
||||
)
|
||||
def content(self, request, pk=None):
|
||||
"""
|
||||
Retrieve document content in different formats (JSON, Markdown, HTML).
|
||||
|
||||
Query parameters:
|
||||
- content_format: The desired output format (json, markdown, html)
|
||||
|
||||
Returns:
|
||||
JSON response with content in the specified format.
|
||||
"""
|
||||
|
||||
document = self.get_object()
|
||||
|
||||
content_format = request.query_params.get("content_format", "json").lower()
|
||||
if content_format not in {"json", "markdown", "html"}:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Invalid format. Must be one of: json, markdown, html"
|
||||
)
|
||||
|
||||
# Get the base64 content from the document
|
||||
content = None
|
||||
base64_content = document.content
|
||||
if base64_content is not None:
|
||||
# Convert using the y-provider service
|
||||
try:
|
||||
yprovider = YdocConverter()
|
||||
result = yprovider.convert(
|
||||
base64.b64decode(base64_content),
|
||||
"application/vnd.yjs.doc",
|
||||
{
|
||||
"markdown": "text/markdown",
|
||||
"html": "text/html",
|
||||
"json": "application/json",
|
||||
}[content_format],
|
||||
)
|
||||
content = result
|
||||
except YProviderValidationError as e:
|
||||
return drf_response.Response(
|
||||
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except YProviderServiceUnavailableError as e:
|
||||
logger.error("Error getting content for document %s: %s", pk, e)
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to get document content"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": content,
|
||||
"created_at": document.created_at,
|
||||
"updated_at": document.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -1493,6 +1782,7 @@ class DocumentAccessViewSet(
|
||||
"document__depth",
|
||||
)
|
||||
resource_field_name = "document"
|
||||
throttle_scope = "document_access"
|
||||
|
||||
@cached_property
|
||||
def document(self):
|
||||
@@ -1639,10 +1929,7 @@ class DocumentAccessViewSet(
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
@@ -1652,6 +1939,7 @@ class TemplateViewSet(
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.ResourceWithAccessPermission,
|
||||
]
|
||||
throttle_scope = "template"
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
@@ -1697,99 +1985,6 @@ class TemplateViewSet(
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
@transaction.atomic
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with template accesses.
|
||||
|
||||
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
|
||||
Return list of all template accesses related to the logged-in user or one
|
||||
template access if an id is provided.
|
||||
|
||||
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [administrator|editor|reader]
|
||||
Return newly created template access
|
||||
|
||||
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return updated template access
|
||||
|
||||
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return partially updated template access
|
||||
|
||||
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
|
||||
Delete targeted template access
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
permission_classes = [permissions.ResourceAccessPermission]
|
||||
queryset = models.TemplateAccess.objects.select_related("user").all()
|
||||
resource_field_name = "template"
|
||||
serializer_class = serializers.TemplateAccessSerializer
|
||||
|
||||
@cached_property
|
||||
def template(self):
|
||||
"""Get related template from resource ID in url."""
|
||||
try:
|
||||
return models.Template.objects.get(pk=self.kwargs["resource_id"])
|
||||
except models.Template.DoesNotExist as excpt:
|
||||
raise drf.exceptions.NotFound() from excpt
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
user = self.request.user
|
||||
teams = user.teams
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Limit to resource access instances related to a resource THAT also has
|
||||
# a resource access instance for the logged-in user (we don't want to list
|
||||
# only the resource access instances pointing to the logged-in user)
|
||||
queryset = queryset.filter(
|
||||
db.Q(template__accesses__user=user)
|
||||
| db.Q(template__accesses__team__in=teams),
|
||||
).distinct()
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
Actually create the new template access:
|
||||
- Ensures the `template_id` is explicitly set from the URL.
|
||||
- If the assigned role is `OWNER`, checks that the requesting user is an owner
|
||||
of the document. This is the only permission check deferred until this step;
|
||||
all other access checks are handled earlier in the permission lifecycle.
|
||||
"""
|
||||
role = serializer.validated_data.get("role")
|
||||
if (
|
||||
role == choices.RoleChoices.OWNER
|
||||
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
|
||||
):
|
||||
raise drf.exceptions.PermissionDenied(
|
||||
"Only owners of a template can assign other users as owners."
|
||||
)
|
||||
|
||||
serializer.save(template_id=self.kwargs["resource_id"])
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
drf.mixins.CreateModelMixin,
|
||||
@@ -1824,6 +2019,7 @@ class InvitationViewset(
|
||||
permissions.CanCreateInvitationPermission,
|
||||
permissions.ResourceWithAccessPermission,
|
||||
]
|
||||
throttle_scope = "invitation"
|
||||
queryset = (
|
||||
models.Invitation.objects.all()
|
||||
.select_related("document")
|
||||
@@ -1902,6 +2098,7 @@ class DocumentAskForAccessViewSet(
|
||||
permissions.IsAuthenticated,
|
||||
permissions.ResourceWithAccessPermission,
|
||||
]
|
||||
throttle_scope = "document_ask_for_access"
|
||||
queryset = models.DocumentAskForAccess.objects.all()
|
||||
serializer_class = serializers.DocumentAskForAccessSerializer
|
||||
_document = None
|
||||
@@ -1966,7 +2163,18 @@ class DocumentAskForAccessViewSet(
|
||||
serializer = serializers.RoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
|
||||
target_role = serializer.validated_data.get(
|
||||
"role", document_ask_for_access.role
|
||||
)
|
||||
abilities = document_ask_for_access.get_abilities(request.user)
|
||||
|
||||
if target_role not in abilities["set_role_to"]:
|
||||
return drf.response.Response(
|
||||
{"detail": "You cannot accept a role higher than your own."},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
document_ask_for_access.accept(role=target_role)
|
||||
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -1974,6 +2182,7 @@ class ConfigView(drf.views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = "config"
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
@@ -1988,12 +2197,14 @@ class ConfigView(drf.views.APIView):
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_JS_URL",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
"TRASHBIN_CUTOFF_DAYS",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
@@ -2038,3 +2249,132 @@ class ConfigView(drf.views.APIView):
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
|
||||
class CommentViewSetMixin:
|
||||
"""Comment ViewSet Mixin."""
|
||||
|
||||
_document = None
|
||||
|
||||
def get_document_or_404(self):
|
||||
"""Get the document related to the viewset or raise a 404 error."""
|
||||
if self._document is None:
|
||||
try:
|
||||
self._document = models.Document.objects.get(
|
||||
pk=self.kwargs["resource_id"],
|
||||
)
|
||||
except models.Document.DoesNotExist as e:
|
||||
raise drf.exceptions.NotFound("Document not found.") from e
|
||||
return self._document
|
||||
|
||||
|
||||
class ThreadViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
CommentViewSetMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Thread API: list/create threads and nested comment operations."""
|
||||
|
||||
permission_classes = [permissions.CommentPermission]
|
||||
pagination_class = Pagination
|
||||
serializer_class = serializers.ThreadSerializer
|
||||
queryset = models.Thread.objects.select_related("creator", "document").filter(
|
||||
resolved=False
|
||||
)
|
||||
resource_field_name = "document"
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create the first comment of the thread."""
|
||||
body = serializer.validated_data["body"]
|
||||
del serializer.validated_data["body"]
|
||||
thread = serializer.save()
|
||||
|
||||
models.Comment.objects.create(
|
||||
thread=thread,
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
body=body,
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
|
||||
def resolve(self, request, *args, **kwargs):
|
||||
"""Resolve a thread."""
|
||||
thread = self.get_object()
|
||||
if not thread.resolved:
|
||||
thread.resolved = True
|
||||
thread.resolved_at = timezone.now()
|
||||
thread.resolved_by = request.user
|
||||
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
|
||||
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CommentViewSet(
|
||||
CommentViewSetMixin,
|
||||
viewsets.ModelViewSet,
|
||||
):
|
||||
"""Comment API: list/create comments and nested reaction operations."""
|
||||
|
||||
permission_classes = [permissions.CommentPermission]
|
||||
pagination_class = Pagination
|
||||
serializer_class = serializers.CommentSerializer
|
||||
queryset = models.Comment.objects.select_related("user").all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override to filter on related resource."""
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
thread=self.kwargs["thread_id"],
|
||||
thread__document=self.kwargs["resource_id"],
|
||||
)
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["document_id"] = self.kwargs["resource_id"]
|
||||
context["thread_id"] = self.kwargs["thread_id"]
|
||||
return context
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post", "delete"],
|
||||
)
|
||||
def reactions(self, request, *args, **kwargs):
|
||||
"""POST: add reaction; DELETE: remove reaction.
|
||||
|
||||
Emoji is expected in request.data['emoji'] for both operations.
|
||||
"""
|
||||
comment = self.get_object()
|
||||
serializer = serializers.ReactionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if request.method == "POST":
|
||||
reaction, created = models.Reaction.objects.get_or_create(
|
||||
comment=comment,
|
||||
emoji=serializer.validated_data["emoji"],
|
||||
)
|
||||
if not created and reaction.users.filter(id=request.user.id).exists():
|
||||
return drf.response.Response(
|
||||
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
reaction.users.add(request.user)
|
||||
return drf.response.Response(status=status.HTTP_201_CREATED)
|
||||
|
||||
# DELETE
|
||||
try:
|
||||
reaction = models.Reaction.objects.get(
|
||||
comment=comment,
|
||||
emoji=serializer.validated_data["emoji"],
|
||||
users__in=[request.user],
|
||||
)
|
||||
except models.Reaction.DoesNotExist as e:
|
||||
raise drf.exceptions.NotFound("Reaction not found.") from e
|
||||
reaction.users.remove(request.user)
|
||||
if not reaction.users.exists():
|
||||
reaction.delete()
|
||||
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Impress Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the impress core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("Impress core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
@@ -57,3 +58,22 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
|
||||
if is_new_user and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
create_or_update_contact.delay(
|
||||
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
|
||||
"""Defines the possible roles a link can offer on a document."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
COMMENTER = "commenter", _("Commenter") # Can read and comment
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
|
||||
|
||||
@@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices):
|
||||
"""Defines the possible roles a user can have in a resource."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
COMMENTER = "commenter", _("Commenter") # Can read and comment
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
@@ -150,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""Add link traces to document from a given list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
models.LinkTrace.objects.update_or_create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def favorited_by(self, create, extracted, **kwargs):
|
||||
@@ -159,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
for item in extracted:
|
||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def masked_by(self, create, extracted, **kwargs):
|
||||
"""Mark document as masked by a list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.update_or_create(
|
||||
document=self, user=item, defaults={"is_masked": True}
|
||||
)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
@@ -247,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class ThreadFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create threads for a document"""
|
||||
|
||||
class Meta:
|
||||
model = models.Thread
|
||||
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class CommentFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create comments for a thread"""
|
||||
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
|
||||
thread = factory.SubFactory(ThreadFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
body = factory.Faker("text")
|
||||
|
||||
|
||||
class ReactionFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create reactions for a comment"""
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
|
||||
comment = factory.SubFactory(CommentFactory)
|
||||
emoji = "test"
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to reaction from a given list of users or create one if not provided."""
|
||||
if not create:
|
||||
return
|
||||
|
||||
if not extracted:
|
||||
# the factory is being created, but no users were provided
|
||||
user = UserFactory()
|
||||
self.users.add(user)
|
||||
return
|
||||
|
||||
# Add the iterable of groups using bulk addition
|
||||
self.users.add(*extracted)
|
||||
|
||||
52
src/backend/core/management/commands/index.py
Normal file
52
src/backend/core/management/commands/index.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Handle search setup that needs to be done at bootstrap time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Index all documents to remote search service"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add argument to require forcing execution when not in debug mode."""
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
action="store",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Indexation query batch size",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Launch and log search index generation."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
try:
|
||||
count = indexer.index(batch_size=batch_size)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.3 on 2025-07-13 08:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0023_remove_document_is_public_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="linktrace",
|
||||
name="is_masked",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
("nl-nl", "Nederlands"),
|
||||
("es-es", "Español"),
|
||||
],
|
||||
default=None,
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
null=True,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="sub",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. ASCII characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[core.validators.sub_validator],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
]
|
||||
19
src/backend/core/migrations/0025_alter_user_short_name.py
Normal file
19
src/backend/core/migrations/0025_alter_user_short_name.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-22 06:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0024_add_is_masked_field_to_link_trace"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="short_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=100, null=True, verbose_name="short name"
|
||||
),
|
||||
),
|
||||
]
|
||||
275
src/backend/core/migrations/0026_comments.py
Normal file
275
src/backend/core/migrations/0026_comments.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-16 08:59
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0025_alter_user_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="link_role",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("commenter", "Commenter"),
|
||||
("editor", "Editor"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="documentaccess",
|
||||
name="role",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("commenter", "Commenter"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="documentaskforaccess",
|
||||
name="role",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("commenter", "Commenter"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invitation",
|
||||
name="role",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("commenter", "Commenter"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="templateaccess",
|
||||
name="role",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("commenter", "Commenter"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Thread",
|
||||
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",
|
||||
),
|
||||
),
|
||||
("resolved", models.BooleanField(default=False)),
|
||||
("resolved_at", models.DateTimeField(blank=True, null=True)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"creator",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="threads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="threads",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"resolved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_threads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Thread",
|
||||
"verbose_name_plural": "Threads",
|
||||
"db_table": "impress_thread",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Comment",
|
||||
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",
|
||||
),
|
||||
),
|
||||
("body", models.JSONField()),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="thread_comment",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"thread",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="comments",
|
||||
to="core.thread",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Comment",
|
||||
"verbose_name_plural": "Comments",
|
||||
"db_table": "impress_comment",
|
||||
"ordering": ("created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Reaction",
|
||||
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",
|
||||
),
|
||||
),
|
||||
("emoji", models.CharField(max_length=32)),
|
||||
(
|
||||
"comment",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reactions",
|
||||
to="core.comment",
|
||||
),
|
||||
),
|
||||
(
|
||||
"users",
|
||||
models.ManyToManyField(
|
||||
related_name="reactions", to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Reaction",
|
||||
"verbose_name_plural": "Reactions",
|
||||
"db_table": "impress_comment_reaction",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("comment", "emoji"),
|
||||
name="unique_comment_emoji",
|
||||
violation_error_message="This emoji has already been reacted to this comment.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
37
src/backend/core/migrations/0027_auto_20251120_0956.py
Normal file
37
src/backend/core/migrations/0027_auto_20251120_0956.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 09:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0026_comments"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE OR REPLACE FUNCTION public.immutable_unaccent(regdictionary, text)
|
||||
RETURNS text
|
||||
LANGUAGE c IMMUTABLE PARALLEL SAFE STRICT AS
|
||||
'$libdir/unaccent', 'unaccent_dict';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
||||
RETURN public.immutable_unaccent(regdictionary 'public.unaccent', $1);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_email_unaccent_trgm_idx
|
||||
ON impress_user
|
||||
USING gin (f_unaccent(email) gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_full_name_unaccent_trgm_idx
|
||||
ON impress_user
|
||||
USING gin (f_unaccent(full_name) gin_trgm_ops);
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS user_email_unaccent_trgm_idx;
|
||||
DROP INDEX IF EXISTS user_full_name_unaccent_trgm_idx;
|
||||
""",
|
||||
),
|
||||
]
|
||||
@@ -14,7 +14,7 @@ from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail, validators
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -39,6 +39,7 @@ from .choices import (
|
||||
RoleChoices,
|
||||
get_equivalent_link_definition,
|
||||
)
|
||||
from .validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -136,28 +137,20 @@ class UserManager(auth_models.UserManager):
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-:]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
),
|
||||
help_text=_("Required. 255 characters or fewer. ASCII characters only."),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
unique=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
|
||||
short_name = models.CharField(
|
||||
_("short name"), max_length=100, null=True, blank=True
|
||||
)
|
||||
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
@@ -230,7 +223,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
valid_invitations = Invitation.objects.filter(
|
||||
email=self.email,
|
||||
email__iexact=self.email,
|
||||
created_at__gte=(
|
||||
timezone.now()
|
||||
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
@@ -439,32 +432,35 @@ class Document(MP_Node, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
self.save_content(self._content)
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
def save_content(self, content):
|
||||
"""Save content to object storage."""
|
||||
|
||||
file_key = self.file_key
|
||||
bytes_content = content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
@@ -730,7 +726,7 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
# Characteristics that are based only on specific access
|
||||
is_owner = role == RoleChoices.OWNER
|
||||
is_deleted = self.ancestors_deleted_at and not is_owner
|
||||
is_deleted = self.ancestors_deleted_at
|
||||
is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted
|
||||
|
||||
# Compute access roles before adding link roles because we don't
|
||||
@@ -759,9 +755,17 @@ class Document(MP_Node, BaseModel):
|
||||
role = RoleChoices.max(role, link_definition["link_role"])
|
||||
|
||||
can_get = bool(role) and not is_deleted
|
||||
retrieve = can_get or is_owner
|
||||
can_update = (
|
||||
is_owner_or_admin or role == RoleChoices.EDITOR
|
||||
) and not is_deleted
|
||||
can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted
|
||||
can_create_children = can_update and user.is_authenticated
|
||||
can_destroy = (
|
||||
is_owner
|
||||
if self.is_root()
|
||||
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
|
||||
) and not is_deleted
|
||||
|
||||
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
||||
ai_access = any(
|
||||
@@ -784,22 +788,25 @@ class Document(MP_Node, BaseModel):
|
||||
"media_check": can_get,
|
||||
"can_edit": can_update,
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
"children_create": can_create_children,
|
||||
"collaboration_auth": can_get,
|
||||
"comment": can_comment,
|
||||
"content": can_get,
|
||||
"cors_proxy": can_get,
|
||||
"descendants": can_get,
|
||||
"destroy": is_owner,
|
||||
"destroy": can_destroy,
|
||||
"duplicate": can_get and user.is_authenticated,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": is_owner,
|
||||
"move": is_owner_or_admin and not self.ancestors_deleted_at,
|
||||
"invite_owner": is_owner and not is_deleted,
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"restore": is_owner,
|
||||
"retrieve": can_get,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": can_get,
|
||||
"link_select_options": link_select_options,
|
||||
"tree": can_get,
|
||||
"tree": retrieve,
|
||||
"update": can_update,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_access_role,
|
||||
@@ -899,7 +906,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at
|
||||
ancestors_deleted_at=self.ancestors_deleted_at,
|
||||
updated_at=self.updated_at,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
@@ -958,6 +966,7 @@ class LinkTrace(BaseModel):
|
||||
related_name="link_traces",
|
||||
)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
||||
is_masked = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_link_trace"
|
||||
@@ -1143,7 +1152,12 @@ class DocumentAccess(BaseAccess):
|
||||
set_role_to = []
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
|
||||
[
|
||||
RoleChoices.READER,
|
||||
RoleChoices.COMMENTER,
|
||||
RoleChoices.EDITOR,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
)
|
||||
if role == RoleChoices.OWNER:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
@@ -1202,23 +1216,14 @@ class DocumentAskForAccess(BaseModel):
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
roles = []
|
||||
user_role = self.document.get_role(user)
|
||||
is_admin_or_owner = user_role in PRIVILEGED_ROLES
|
||||
|
||||
if user.is_authenticated:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.document.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
is_admin_or_owner = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
set_role_to = [
|
||||
role
|
||||
for role in RoleChoices.values
|
||||
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
|
||||
]
|
||||
|
||||
return {
|
||||
"destroy": is_admin_or_owner,
|
||||
@@ -1226,6 +1231,7 @@ class DocumentAskForAccess(BaseModel):
|
||||
"partial_update": is_admin_or_owner,
|
||||
"retrieve": is_admin_or_owner,
|
||||
"accept": is_admin_or_owner,
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
def accept(self, role=None):
|
||||
@@ -1275,6 +1281,153 @@ class DocumentAskForAccess(BaseModel):
|
||||
self.document.send_email(subject, [email], context, language)
|
||||
|
||||
|
||||
class Thread(BaseModel):
|
||||
"""Discussion thread attached to a document.
|
||||
|
||||
A thread groups one or many comments. For backward compatibility with the
|
||||
existing frontend (useComments hook) we still expose a flattened serializer
|
||||
that returns a "content" field representing the first comment's body.
|
||||
"""
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="threads",
|
||||
)
|
||||
creator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="threads",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
resolved = models.BooleanField(default=False)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
resolved_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_threads",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_thread"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Thread")
|
||||
verbose_name_plural = _("Threads")
|
||||
|
||||
def __str__(self):
|
||||
author = self.creator or _("Anonymous")
|
||||
return f"Thread by {author!s} on {self.document!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user (mirrors comment logic)."""
|
||||
role = self.document.get_role(user)
|
||||
doc_abilities = self.document.get_abilities(user)
|
||||
read_access = doc_abilities.get("comment", False)
|
||||
write_access = self.creator == user or role in [
|
||||
RoleChoices.OWNER,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
return {
|
||||
"destroy": write_access,
|
||||
"update": write_access,
|
||||
"partial_update": write_access,
|
||||
"resolve": write_access,
|
||||
"retrieve": read_access,
|
||||
}
|
||||
|
||||
@property
|
||||
def first_comment(self):
|
||||
"""Return the first createdcomment of the thread."""
|
||||
return self.comments.order_by("created_at").first()
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
"""A comment belonging to a thread."""
|
||||
|
||||
thread = models.ForeignKey(
|
||||
Thread,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comments",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="thread_comment",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
body = models.JSONField()
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_comment"
|
||||
ordering = ("created_at",)
|
||||
verbose_name = _("Comment")
|
||||
verbose_name_plural = _("Comments")
|
||||
|
||||
def __str__(self):
|
||||
"""Return the string representation of the comment."""
|
||||
author = self.user or _("Anonymous")
|
||||
return f"Comment by {author!s} on thread {self.thread_id}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Return the abilities of the comment."""
|
||||
role = self.thread.document.get_role(user)
|
||||
doc_abilities = self.thread.document.get_abilities(user)
|
||||
read_access = doc_abilities.get("comment", False)
|
||||
can_react = read_access and user.is_authenticated
|
||||
write_access = self.user == user or role in [
|
||||
RoleChoices.OWNER,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
return {
|
||||
"destroy": write_access,
|
||||
"update": write_access,
|
||||
"partial_update": write_access,
|
||||
"reactions": can_react,
|
||||
"retrieve": read_access,
|
||||
}
|
||||
|
||||
|
||||
class Reaction(BaseModel):
|
||||
"""Aggregated reactions for a given emoji on a comment.
|
||||
|
||||
We store one row per (comment, emoji) and maintain the list of user IDs who
|
||||
reacted with that emoji. This matches the frontend interface where a
|
||||
reaction exposes: emoji, createdAt (first reaction date) and userIds.
|
||||
"""
|
||||
|
||||
comment = models.ForeignKey(
|
||||
Comment,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reactions",
|
||||
)
|
||||
emoji = models.CharField(max_length=32)
|
||||
users = models.ManyToManyField(User, related_name="reactions")
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_comment_reaction"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["comment", "emoji"],
|
||||
name="unique_comment_emoji",
|
||||
violation_error_message=_(
|
||||
"This emoji has already been reacted to this comment."
|
||||
),
|
||||
),
|
||||
]
|
||||
verbose_name = _("Reaction")
|
||||
verbose_name_plural = _("Reactions")
|
||||
|
||||
def __str__(self):
|
||||
"""Return the string representation of the reaction."""
|
||||
return f"Reaction {self.emoji} on comment {self.comment.id}"
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Converter services."""
|
||||
"""Y-Provider API services."""
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
@@ -28,25 +28,44 @@ class YdocConverter:
|
||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
|
||||
|
||||
def convert(self, text):
|
||||
def _request(self, url, data, content_type, accept):
|
||||
"""Make a request to the Y-Provider API."""
|
||||
response = requests.post(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": self.auth_header,
|
||||
"Content-Type": content_type,
|
||||
"Accept": accept,
|
||||
},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def convert(
|
||||
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
|
||||
):
|
||||
"""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(
|
||||
response = self._request(
|
||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||
data=text,
|
||||
headers={
|
||||
"Authorization": self.auth_header,
|
||||
"Content-Type": "text/markdown",
|
||||
},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
text,
|
||||
content_type,
|
||||
accept,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return b64encode(response.content).decode("utf-8")
|
||||
if accept == "application/vnd.yjs.doc":
|
||||
return b64encode(response.content).decode("utf-8")
|
||||
if accept in {"text/markdown", "text/html"}:
|
||||
return response.text
|
||||
if accept == "application/json":
|
||||
return response.json()
|
||||
raise ValidationError("Unsupported format")
|
||||
except requests.RequestException as err:
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to conversion service",
|
||||
|
||||
298
src/backend/core/services/search_indexers.py
Normal file
298
src/backend/core/services/search_indexers.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_document_indexer():
|
||||
"""Returns an instance of indexer service if enabled and properly configured."""
|
||||
classpath = settings.SEARCH_INDEXER_CLASS
|
||||
|
||||
# For this usecase an empty indexer class is not an issue but a feature.
|
||||
if not classpath:
|
||||
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
|
||||
return None
|
||||
|
||||
try:
|
||||
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
|
||||
return indexer_class()
|
||||
except ImportError as err:
|
||||
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
|
||||
except ImproperlyConfigured as err:
|
||||
logger.error("Document indexer is not properly configured : %s", err)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_batch_accesses_by_users_and_teams(paths):
|
||||
"""
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
|
||||
access_qs = models.DocumentAccess.objects.filter(
|
||||
document__path__in=ancestor_paths
|
||||
).values("document__path", "user__sub", "team")
|
||||
|
||||
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
|
||||
|
||||
for access in access_qs:
|
||||
ancestor_path = access["document__path"]
|
||||
user_sub = access["user__sub"]
|
||||
team = access["team"]
|
||||
|
||||
for descendant_path in ancestor_map.get(ancestor_path, []):
|
||||
if user_sub:
|
||||
access_by_document_path[descendant_path]["users"].add(str(user_sub))
|
||||
if team:
|
||||
access_by_document_path[descendant_path]["teams"].add(team)
|
||||
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
"visited" by the user.
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
.filter(
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
Base class for document indexers.
|
||||
|
||||
Handles batching and access resolution. Subclasses must implement both
|
||||
`serialize_document()` and `push()` to define backend-specific behavior.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_SECRET must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self, queryset=None, batch_size=None):
|
||||
"""
|
||||
Fetch documents in batches, serialize them, and push to the search backend.
|
||||
|
||||
Args:
|
||||
queryset (optional): Document queryset
|
||||
Defaults to all documents without filter.
|
||||
batch_size (int, optional): Number of documents per batch.
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
"""
|
||||
last_id = 0
|
||||
count = 0
|
||||
queryset = queryset or models.Document.objects.all()
|
||||
batch_size = batch_size or self.batch_size
|
||||
|
||||
while True:
|
||||
documents_batch = list(
|
||||
queryset.filter(
|
||||
id__gt=last_id,
|
||||
).order_by("id")[:batch_size]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
doc_paths = [doc.path for doc in documents_batch]
|
||||
last_id = documents_batch[-1].id
|
||||
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
|
||||
|
||||
serialized_batch = [
|
||||
self.serialize_document(document, accesses_by_document_path)
|
||||
for document in documents_batch
|
||||
if document.content or document.title
|
||||
]
|
||||
|
||||
if serialized_batch:
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
@abstractmethod
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document instance to a JSON-serializable format for indexing.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of serialized documents to the backend.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
accesses (dict): Mapping of document ID to user/team access.
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
"title": document.title or "",
|
||||
"content": text_content,
|
||||
"depth": document.depth,
|
||||
"path": document.path,
|
||||
"numchild": document.numchild,
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"users": list(accesses.get(doc_path, {}).get("users", set())),
|
||||
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
|
||||
"reach": document.computed_link_reach,
|
||||
"size": len(text_content.encode("utf-8")),
|
||||
"is_active": not bool(document.ancestors_deleted_at),
|
||||
}
|
||||
|
||||
def search_query(self, data, token) -> requests.Response:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Args:
|
||||
data (dict): search data
|
||||
token (str): OICD token
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.search_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of documents to the Find backend.
|
||||
|
||||
Args:
|
||||
data (list): List of document dictionaries.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.indexer_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.indexer_secret}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
33
src/backend/core/signals.py
Normal file
33
src/backend/core/signals.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Declare and configure the signals for the impress core application
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from . import models
|
||||
from .tasks.search import trigger_batch_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Note : Within the transaction we can have an empty content and a serialization
|
||||
error.
|
||||
"""
|
||||
transaction.on_commit(partial(trigger_batch_document_indexer, instance))
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.DocumentAccess)
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(
|
||||
partial(trigger_batch_document_indexer, instance.document)
|
||||
)
|
||||
95
src/backend/core/tasks/search.py
Normal file
95
src/backend/core/tasks/search.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
|
||||
from django_redis.cache import RedisCache
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
@app.task
|
||||
def document_indexer_task(document_id):
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
logger.info("Start document %s indexation", document_id)
|
||||
indexer.index(models.Document.objects.filter(pk=document_id))
|
||||
|
||||
|
||||
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
|
||||
"""
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = "document-batch-indexer-throttle"
|
||||
|
||||
# Redis is used as cache database (not in tests). Use the lock feature here
|
||||
# to ensure atomicity of changes to the throttle flag.
|
||||
if isinstance(cache, RedisCache) and atomic:
|
||||
with cache.locks(key):
|
||||
return batch_indexer_throttle_acquire(timeout, atomic=False)
|
||||
|
||||
# Use add() here :
|
||||
# - set the flag and returns true if not exist
|
||||
# - do nothing and return false if exist
|
||||
return cache.add(key, 1, timeout=timeout)
|
||||
|
||||
|
||||
@app.task
|
||||
def batch_document_indexer_task(timestamp):
|
||||
"""Celery Task : Sends indexation query for a batch of documents."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
queryset = models.Document.objects.filter(
|
||||
Q(updated_at__gte=timestamp)
|
||||
| Q(deleted_at__gte=timestamp)
|
||||
| Q(ancestors_deleted_at__gte=timestamp)
|
||||
)
|
||||
|
||||
count = indexer.index(queryset)
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
if countdown > 0:
|
||||
# Each time this method is called during a countdown, we increment the
|
||||
# counter and each task decrease it, so the index be run only once.
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import random
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -12,7 +13,10 @@ from cryptography.fernet import Fernet
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.backends import (
|
||||
OIDCAuthenticationBackend,
|
||||
create_or_update_contact,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -57,7 +61,7 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(3): # user by sub, user by mail, update sub
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -214,7 +218,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -256,7 +260,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
||||
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):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -509,3 +513,79 @@ def test_authentication_session_tokens(
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_new_user_to_marketing_email(settings):
|
||||
"""
|
||||
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, True)
|
||||
mock_create_or_update_contact.assert_called_once_with(
|
||||
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_new_user_to_marketing_email_disabled(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, True)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, False)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email_disabled(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, False)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
65
src/backend/core/tests/commands/test_index.py
Normal file
65
src/backend/core/tests/commands/test_index.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
empty_doc = factories.DocumentFactory(title=None, content="")
|
||||
no_title_doc = factories.DocumentFactory(title=None)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(empty_doc.path): {"users": [user.sub]},
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
|
||||
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(no_title_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_improperly_configured(indexer_settings):
|
||||
"""The command should raise an exception if the indexer is not configured"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with pytest.raises(CommandError) as err:
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
@@ -24,3 +24,30 @@ def mock_user_teams():
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
@pytest.fixture(name="indexer_settings")
|
||||
def indexer_settings_fixture(settings):
|
||||
"""
|
||||
Setup valid settings for the document indexer. Clear the indexer cache.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from core.services.search_indexers import ( # noqa: PLC0415
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
@@ -4,6 +4,7 @@ Test document accesses API endpoints for users in impress's core app.
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -292,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
}
|
||||
assert result_dict[str(document_access_other_user.id)] == [
|
||||
"reader",
|
||||
"commenter",
|
||||
"editor",
|
||||
"administrator",
|
||||
"owner",
|
||||
@@ -300,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
|
||||
# Add an access for the other user on the parent
|
||||
parent_access_other_user = factories.UserDocumentAccessFactory(
|
||||
document=parent, user=other_user, role="editor"
|
||||
document=parent, user=other_user, role="commenter"
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
@@ -313,6 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||
}
|
||||
assert result_dict[str(document_access_other_user.id)] == [
|
||||
"commenter",
|
||||
"editor",
|
||||
"administrator",
|
||||
"owner",
|
||||
@@ -320,6 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
assert result_dict[str(parent_access.id)] == []
|
||||
assert result_dict[str(parent_access_other_user.id)] == [
|
||||
"reader",
|
||||
"commenter",
|
||||
"editor",
|
||||
"administrator",
|
||||
"owner",
|
||||
@@ -332,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
[
|
||||
["administrator", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -414,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
|
||||
[
|
||||
["administrator", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["reader", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["reader", "administrator", "reader", "editor"],
|
||||
[
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
@@ -459,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
|
||||
[
|
||||
["editor", "editor", "administrator", "editor"],
|
||||
[
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
[],
|
||||
["editor", "administrator"],
|
||||
[],
|
||||
@@ -1344,3 +1348,24 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_document_accesses_throttling(settings):
|
||||
"""Test api document accesses throttling."""
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for _i in range(2):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope document_access", "warning"
|
||||
)
|
||||
|
||||
@@ -596,6 +596,32 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations_create_lower_email():
|
||||
"""
|
||||
No matter the case, the email should be converted to lowercase.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
# Build an invitation to the email of an existing identity in the db
|
||||
invitation_values = {
|
||||
"email": "GuEst@example.com",
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
|
||||
|
||||
# Update
|
||||
|
||||
|
||||
@@ -743,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
|
||||
assert value == old_invitation_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_document_invitations_patch(via, role, mock_user_teams):
|
||||
"""Partially updating an invitation should be allowed."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory(role="editor")
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role=role
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
|
||||
{"role": "reader"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.role == "reader"
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
@@ -824,3 +881,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations_throttling(settings):
|
||||
"""Test api document ask for access throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
factories.InvitationFactory(document=document, issuer=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _i in range(2):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope invitation", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test API for document ask for access."""
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core import mail
|
||||
|
||||
@@ -114,7 +115,10 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
||||
@pytest.mark.parametrize(
|
||||
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
|
||||
)
|
||||
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
|
||||
"""
|
||||
Authenticated users should be able to create a document ask for access with a specific role.
|
||||
"""
|
||||
@@ -126,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||
data={"role": RoleChoices.EDITOR},
|
||||
data={"role": role},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert DocumentAskForAccess.objects.filter(
|
||||
document=document,
|
||||
user=user,
|
||||
role=RoleChoices.EDITOR,
|
||||
role=role,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_documents_ask_for_access_create_authenticated_owner_role():
|
||||
"""
|
||||
Authenticated users should not be able to create a document ask for access with the owner role.
|
||||
"""
|
||||
document = DocumentFactory()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||
data={"role": RoleChoices.OWNER},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"role": ['"owner" is not a valid choice.']}
|
||||
|
||||
|
||||
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
|
||||
"""Authenticated users with existing access can ask for access with a different role."""
|
||||
user = UserFactory()
|
||||
@@ -265,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"set_role_to": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
@@ -334,6 +357,16 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_set_role_to = [
|
||||
RoleChoices.READER,
|
||||
RoleChoices.COMMENTER,
|
||||
RoleChoices.EDITOR,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
if role == RoleChoices.OWNER:
|
||||
expected_set_role_to.append(RoleChoices.OWNER)
|
||||
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
@@ -353,6 +386,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": expected_set_role_to,
|
||||
},
|
||||
}
|
||||
for document_ask_for_access in document_ask_for_accesses
|
||||
@@ -445,6 +479,14 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
expected_set_role_to = [
|
||||
RoleChoices.READER,
|
||||
RoleChoices.COMMENTER,
|
||||
RoleChoices.EDITOR,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
if role == RoleChoices.OWNER:
|
||||
expected_set_role_to.append(RoleChoices.OWNER)
|
||||
assert response.json() == {
|
||||
"id": str(document_ask_for_access.id),
|
||||
"document": str(document.id),
|
||||
@@ -459,6 +501,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": expected_set_role_to,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -748,6 +791,53 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
|
||||
assert document_access.role == RoleChoices.ADMIN
|
||||
|
||||
|
||||
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
|
||||
"""
|
||||
Admin users should not be able to accept document ask for access with the owner role.
|
||||
"""
|
||||
user = UserFactory()
|
||||
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
document_ask_for_access = DocumentAskForAccessFactory(
|
||||
document=document, role=RoleChoices.READER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
|
||||
data={"role": RoleChoices.OWNER},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"detail": "You cannot accept a role higher than your own."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
|
||||
"""
|
||||
Owner users should be able to accept document ask for access with the owner role.
|
||||
"""
|
||||
user = UserFactory()
|
||||
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
|
||||
document_ask_for_access = DocumentAskForAccessFactory(
|
||||
document=document, role=RoleChoices.READER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
|
||||
data={"role": RoleChoices.OWNER},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
assert not DocumentAskForAccess.objects.filter(
|
||||
id=document_ask_for_access.id
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
|
||||
"""
|
||||
@@ -768,3 +858,35 @@ def test_api_documents_ask_for_access_accept_authenticated_non_root_document(rol
|
||||
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_document_ask_for_access_throttling(settings):
|
||||
"""Test api document ask for access throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][
|
||||
"document_ask_for_access"
|
||||
]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
|
||||
"2/minute"
|
||||
)
|
||||
document = DocumentFactory()
|
||||
DocumentAskForAccessFactory.create_batch(
|
||||
3, document=document, role=RoleChoices.READER
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _i in range(2):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope document_ask_for_access", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
|
||||
current_rate
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -63,6 +64,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -115,6 +117,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -137,6 +140,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -208,6 +212,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -230,6 +235,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -287,6 +293,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -309,6 +316,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -393,6 +401,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -415,6 +424,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -475,6 +485,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -497,6 +508,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -609,6 +621,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -631,6 +644,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
|
||||
878
src/backend/core/tests/documents/test_api_documents_comments.py
Normal file
878
src/backend/core/tests/documents/test_api_documents_comments.py
Normal file
@@ -0,0 +1,878 @@
|
||||
"""Test API for comments on documents."""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# List comments
|
||||
|
||||
|
||||
def test_list_comments_anonymous_user_public_document():
|
||||
"""Anonymous users should be allowed to list comments on a public document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
|
||||
# other comments not linked to the document
|
||||
factories.CommentFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": str(comment1.id),
|
||||
"body": comment1.body,
|
||||
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user": {
|
||||
"full_name": comment1.user.full_name,
|
||||
"short_name": comment1.user.short_name,
|
||||
},
|
||||
"abilities": comment1.get_abilities(AnonymousUser()),
|
||||
"reactions": [],
|
||||
},
|
||||
{
|
||||
"id": str(comment2.id),
|
||||
"body": comment2.body,
|
||||
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user": {
|
||||
"full_name": comment2.user.full_name,
|
||||
"short_name": comment2.user.short_name,
|
||||
},
|
||||
"abilities": comment2.get_abilities(AnonymousUser()),
|
||||
"reactions": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
|
||||
def test_list_comments_anonymous_user_non_public_document(link_reach):
|
||||
"""Anonymous users should not be allowed to list comments on a non-public document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
factories.CommentFactory(thread=thread)
|
||||
# other comments not linked to the document
|
||||
factories.CommentFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_comments_authenticated_user_accessible_document():
|
||||
"""Authenticated users should be allowed to list comments on an accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment1 = factories.CommentFactory(thread=thread)
|
||||
comment2 = factories.CommentFactory(thread=thread, user=user)
|
||||
# other comments not linked to the document
|
||||
factories.CommentFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": str(comment1.id),
|
||||
"body": comment1.body,
|
||||
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user": {
|
||||
"full_name": comment1.user.full_name,
|
||||
"short_name": comment1.user.short_name,
|
||||
},
|
||||
"abilities": comment1.get_abilities(user),
|
||||
"reactions": [],
|
||||
},
|
||||
{
|
||||
"id": str(comment2.id),
|
||||
"body": comment2.body,
|
||||
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user": {
|
||||
"full_name": comment2.user.full_name,
|
||||
"short_name": comment2.user.short_name,
|
||||
},
|
||||
"abilities": comment2.get_abilities(user),
|
||||
"reactions": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_list_comments_authenticated_user_non_accessible_document():
|
||||
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
factories.CommentFactory(thread=thread)
|
||||
# other comments not linked to the document
|
||||
factories.CommentFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_list_comments_authenticated_user_not_enough_access():
|
||||
"""
|
||||
Authenticated users should not be allowed to list comments on a document they don't have
|
||||
comment access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
factories.CommentFactory(thread=thread)
|
||||
# other comments not linked to the document
|
||||
factories.CommentFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Create comment
|
||||
|
||||
|
||||
def test_create_comment_anonymous_user_public_document():
|
||||
"""
|
||||
Anonymous users should be allowed to create comments on a public document
|
||||
with commenter link_role.
|
||||
"""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
|
||||
{"body": "test"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert response.json() == {
|
||||
"id": str(response.json()["id"]),
|
||||
"body": "test",
|
||||
"created_at": response.json()["created_at"],
|
||||
"updated_at": response.json()["updated_at"],
|
||||
"user": None,
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"reactions": False,
|
||||
"retrieve": True,
|
||||
},
|
||||
"reactions": [],
|
||||
}
|
||||
|
||||
|
||||
def test_create_comment_anonymous_user_non_accessible_document():
|
||||
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
|
||||
{"body": "test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_create_comment_authenticated_user_accessible_document():
|
||||
"""Authenticated users should be allowed to create comments on an accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
|
||||
{"body": "test"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert response.json() == {
|
||||
"id": str(response.json()["id"]),
|
||||
"body": "test",
|
||||
"created_at": response.json()["created_at"],
|
||||
"updated_at": response.json()["updated_at"],
|
||||
"user": {
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
},
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"reactions": True,
|
||||
"retrieve": True,
|
||||
},
|
||||
"reactions": [],
|
||||
}
|
||||
|
||||
|
||||
def test_create_comment_authenticated_user_not_enough_access():
|
||||
"""
|
||||
Authenticated users should not be allowed to create comments on a document they don't have
|
||||
comment access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
|
||||
{"body": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Retrieve comment
|
||||
|
||||
|
||||
def test_retrieve_comment_anonymous_user_public_document():
|
||||
"""Anonymous users should be allowed to retrieve comments on a public document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(comment.id),
|
||||
"body": comment.body,
|
||||
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user": {
|
||||
"full_name": comment.user.full_name,
|
||||
"short_name": comment.user.short_name,
|
||||
},
|
||||
"reactions": [],
|
||||
"abilities": comment.get_abilities(AnonymousUser()),
|
||||
}
|
||||
|
||||
|
||||
def test_retrieve_comment_anonymous_user_non_accessible_document():
|
||||
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_retrieve_comment_authenticated_user_accessible_document():
|
||||
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_retrieve_comment_authenticated_user_not_enough_access():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve comments on a document they don't have
|
||||
comment access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Update comment
|
||||
|
||||
|
||||
def test_update_comment_anonymous_user_public_document():
|
||||
"""Anonymous users should not be allowed to update comments on a public document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_comment_anonymous_user_non_accessible_document():
|
||||
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_comment_authenticated_user_accessible_document():
|
||||
"""Authenticated users should not be able to update comments not their own."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
users=[
|
||||
(
|
||||
user,
|
||||
random.choice(
|
||||
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_update_comment_authenticated_user_own_comment():
|
||||
"""Authenticated users should be able to update comments not their own."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
users=[
|
||||
(
|
||||
user,
|
||||
random.choice(
|
||||
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test", user=user)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
comment.refresh_from_db()
|
||||
assert comment.body == "other content"
|
||||
|
||||
|
||||
def test_update_comment_authenticated_user_not_enough_access():
|
||||
"""
|
||||
Authenticated users should not be allowed to update comments on a document they don't
|
||||
have comment access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_update_comment_authenticated_no_access():
|
||||
"""
|
||||
Authenticated users should not be allowed to update comments on a document they don't
|
||||
have access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
|
||||
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
|
||||
"""
|
||||
Authenticated users should be able to update comments on a document they don't have access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
comment.refresh_from_db()
|
||||
assert comment.body == "other content"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
|
||||
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
|
||||
"""
|
||||
Authenticated users should be able to update comments on a document they don't have access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, body="test", user=user)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
|
||||
{"body": "other content"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
comment.refresh_from_db()
|
||||
assert comment.body == "other content"
|
||||
|
||||
|
||||
# Delete comment
|
||||
|
||||
|
||||
def test_delete_comment_anonymous_user_public_document():
|
||||
"""Anonymous users should not be allowed to delete comments on a public document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_delete_comment_anonymous_user_non_accessible_document():
|
||||
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_delete_comment_authenticated_user_accessible_document_own_comment():
|
||||
"""Authenticated users should be able to delete comments on an accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, user=user)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
|
||||
"""Authenticated users should not be able to delete comments on an accessible document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
|
||||
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
|
||||
"""Authenticated users should be able to delete comments on a document they have access to."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
|
||||
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
|
||||
"""Authenticated users should be able to delete comments on a document they have access to."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread, user=user)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_delete_comment_authenticated_user_not_enough_access():
|
||||
"""
|
||||
Authenticated users should not be able to delete comments on a document they don't
|
||||
have access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Create reaction
|
||||
|
||||
|
||||
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
|
||||
def test_create_reaction_anonymous_user_public_document(link_role):
|
||||
"""No matter the link_role, an anonymous user can not react to a comment."""
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_create_reaction_authenticated_user_public_document():
|
||||
"""
|
||||
Authenticated users should not be able to reaction to a comment on a public document with
|
||||
link_role reader.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_create_reaction_authenticated_user_accessible_public_document():
|
||||
"""
|
||||
Authenticated users should be able to react to a comment on a public document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
|
||||
"""
|
||||
Authenticated users should not be able to react to a comment on a connected document
|
||||
with link_role reader.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"link_role",
|
||||
[
|
||||
role
|
||||
for role in models.LinkRoleChoices.values
|
||||
if role != models.LinkRoleChoices.READER
|
||||
],
|
||||
)
|
||||
def test_create_reaction_authenticated_user_connected_document(link_role):
|
||||
"""
|
||||
Authenticated users should be able to react to a comment on a connected document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="authenticated", link_role=link_role
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
def test_create_reaction_authenticated_user_restricted_accessible_document():
|
||||
"""
|
||||
Authenticated users should not be able to react to a comment on a restricted accessible document
|
||||
they don't have access to.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
|
||||
"""
|
||||
Authenticated users should not be able to react to a comment on a restricted accessible
|
||||
document with role reader.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", link_role=models.LinkRoleChoices.READER
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
|
||||
)
|
||||
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
|
||||
role,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to react to a comment on a restricted accessible document
|
||||
with role commenter.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
).exists()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"user_already_reacted": True}
|
||||
|
||||
|
||||
# Delete reaction
|
||||
|
||||
|
||||
def test_delete_reaction_not_owned_by_the_current_user():
|
||||
"""
|
||||
Users should not be able to delete reactions not owned by the current user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_reaction_owned_by_the_current_user():
|
||||
"""
|
||||
Users should not be able to delete reactions not owned by the current user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
reaction.refresh_from_db()
|
||||
assert reaction.users.exists()
|
||||
176
src/backend/core/tests/documents/test_api_documents_content.py
Normal file
176
src/backend/core/tests/documents/test_api_documents_content.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: content
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("public", "reader"),
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_public(mock_content, reach, role):
|
||||
"""Anonymous users should be allowed to access content of public documents."""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
mock_content.return_value = {"some": "data"}
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == str(document.id)
|
||||
assert data["title"] == document.title
|
||||
assert data["content"] == {"some": "data"}
|
||||
mock_content.assert_called_once_with(
|
||||
base64.b64decode(document.content),
|
||||
"application/vnd.yjs.doc",
|
||||
"application/json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, doc_role, user_role",
|
||||
[
|
||||
("restricted", "reader", "reader"),
|
||||
("restricted", "reader", "editor"),
|
||||
("restricted", "reader", "administrator"),
|
||||
("restricted", "reader", "owner"),
|
||||
("restricted", "editor", "reader"),
|
||||
("restricted", "editor", "editor"),
|
||||
("restricted", "editor", "administrator"),
|
||||
("restricted", "editor", "owner"),
|
||||
("authenticated", "reader", None),
|
||||
("authenticated", "editor", None),
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
|
||||
"""Authenticated users need access to get non-public document content."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
|
||||
mock_content.return_value = {"some": "data"}
|
||||
|
||||
# First anonymous request should fail
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
mock_content.assert_not_called()
|
||||
|
||||
# Login and try again
|
||||
client.force_login(user)
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
# If restricted, we still should not have access
|
||||
if user_role is not None:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_content.assert_not_called()
|
||||
|
||||
# Create an access as a reader. This should unlock the access.
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=user_role
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == str(document.id)
|
||||
assert data["title"] == document.title
|
||||
assert data["content"] == {"some": "data"}
|
||||
mock_content.assert_called_once_with(
|
||||
base64.b64decode(document.content),
|
||||
"application/vnd.yjs.doc",
|
||||
"application/json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_format, accept",
|
||||
[
|
||||
("markdown", "text/markdown"),
|
||||
("html", "text/html"),
|
||||
("json", "application/json"),
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_format(mock_content, content_format, accept):
|
||||
"""Test that the content endpoint returns a specific format."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
mock_content.return_value = {"some": "data"}
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == str(document.id)
|
||||
assert data["title"] == document.title
|
||||
assert data["content"] == {"some": "data"}
|
||||
mock_content.assert_called_once_with(
|
||||
base64.b64decode(document.content), "application/vnd.yjs.doc", accept
|
||||
)
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_invalid_format(mock_request):
|
||||
"""Test that the content endpoint rejects invalid formats."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_request.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_yservice_error(mock_request):
|
||||
"""Test that service errors are handled properly."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
mock_request.side_effect = requests.RequestException()
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
mock_request.assert_called_once()
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_nonexistent_document(mock_request):
|
||||
"""Test that accessing a nonexistent document returns 404."""
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_request.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_empty_document(mock_request):
|
||||
"""Test that accessing an empty document returns empty content."""
|
||||
document = factories.DocumentFactory(link_reach="public", content="")
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == str(document.id)
|
||||
assert data["title"] == document.title
|
||||
assert data["content"] is None
|
||||
mock_request.assert_not_called()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import RequestException
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
@@ -149,3 +150,41 @@ def test_api_docs_cors_proxy_unsupported_media_type():
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"ftp://external-url.com/assets/index.html",
|
||||
"ftps://external-url.com/assets/index.html",
|
||||
"invalid-url.com",
|
||||
"ssh://external-url.com/assets/index.html",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
|
||||
"""Test the CORS proxy API for documents with an invalid URL."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == ["Enter a valid URL."]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_request_failed():
|
||||
"""Test the CORS proxy API for documents with a request failed."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=RequestException("Connection refused"))
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123!!",
|
||||
"sub": "invalid süb",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
@@ -163,10 +163,7 @@ def test_api_documents_create_for_owner_invalid_sub():
|
||||
assert not Document.objects.exists()
|
||||
|
||||
assert response.json() == {
|
||||
"sub": [
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
]
|
||||
"sub": ["Enter a valid sub. This value should be ASCII only."]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -62,6 +63,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -84,6 +86,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -135,6 +138,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -157,6 +161,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -179,6 +184,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -251,6 +257,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -273,6 +280,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -295,6 +303,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -352,6 +361,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -374,6 +384,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -396,6 +407,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -474,6 +486,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -496,6 +509,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -518,6 +532,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -576,6 +591,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -598,6 +614,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -620,6 +637,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
@@ -724,6 +742,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
@@ -746,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
@@ -768,6 +788,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
|
||||
@@ -293,3 +293,28 @@ def test_api_documents_duplicate_non_root_document(role):
|
||||
assert duplicated_accesses.count() == 0
|
||||
assert duplicated_document.is_sibling_of(child)
|
||||
assert duplicated_document.is_child_of(document)
|
||||
|
||||
|
||||
def test_api_documents_duplicate_reader_non_root_document():
|
||||
"""
|
||||
Reader users should be able to duplicate non-root documents but will be
|
||||
created as a root document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "reader")])
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
assert child.get_role(user) == "reader"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||
assert duplicated_document.is_root()
|
||||
assert duplicated_document.accesses.count() == 1
|
||||
assert duplicated_document.accesses.get(user=user).role == "owner"
|
||||
|
||||
@@ -45,7 +45,10 @@ def test_api_document_favorite_anonymous_user(method, reach):
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
"""
|
||||
Authenticated users should be able to mark a document to which they have access
|
||||
as favorite using POST.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
@@ -69,7 +72,10 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_forbidden():
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
"""
|
||||
Authenticated users should not be allowed to mark a document to which they don't
|
||||
have access as favorite using POST.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
|
||||
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# User don't have access to this document, let say it had access and this access has been
|
||||
# removed. It should not be in the favorite list anymore.
|
||||
# If the user doesn't have access to this document (e.g the user had access
|
||||
# and this access was removed), it should not be in the favorite list anymore.
|
||||
factories.DocumentFactory(favorited_by=[user])
|
||||
|
||||
document = factories.UserDocumentAccessFactory(
|
||||
@@ -65,6 +65,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"content": document.content,
|
||||
"depth": document.depth,
|
||||
"excerpt": document.excerpt,
|
||||
|
||||
@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
)
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
instance=factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.EDITOR,
|
||||
)
|
||||
).data
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
@@ -158,3 +164,240 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
assert value == new_document_values[key]
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_role_restricted_forbidden():
|
||||
"""
|
||||
Test that trying to set link_role on a document with restricted link_reach
|
||||
returns a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to set a meaningful role on a restricted document
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
"link_role": models.LinkRoleChoices.EDITOR,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_role" in response.json()
|
||||
assert (
|
||||
"Cannot set link_role when link_reach is 'restricted'"
|
||||
in response.json()["link_role"][0]
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_link_reach_required():
|
||||
"""
|
||||
Test that link_reach is required when updating link configuration.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to update without providing link_reach
|
||||
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_reach" in response.json()
|
||||
assert "This field is required" in response.json()["link_reach"][0]
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_restricted_without_role_success(
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Test that setting link_reach to restricted without specifying link_role succeeds.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Only specify link_reach, not link_role
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
}
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.link_reach == models.LinkReachChoices.RESTRICTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
|
||||
)
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
|
||||
reach,
|
||||
role,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Test that setting non-restricted link_reach with valid link_role succeeds.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
new_data = {
|
||||
"link_reach": reach,
|
||||
"link_role": role,
|
||||
}
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.link_reach == reach
|
||||
assert document.link_role == role
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_with_ancestor_constraints():
|
||||
"""
|
||||
Test that link configuration respects ancestor constraints using get_select_options.
|
||||
This test may need adjustment based on the actual get_select_options implementation.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
child_document = factories.DocumentFactory(
|
||||
parent=parent_document,
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=child_document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to set child to PUBLIC when parent is RESTRICTED
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
"link_role": models.LinkRoleChoices.READER,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_reach" in response.json()
|
||||
assert (
|
||||
"Link reach 'restricted' is not allowed based on parent"
|
||||
in response.json()["link_reach"][0]
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
|
||||
"""
|
||||
Test the specific validation logic that checks if link_role is allowed for link_reach.
|
||||
This tests the code section that validates allowed_roles from get_select_options.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
link_role=models.LinkRoleChoices.EDITOR,
|
||||
)
|
||||
|
||||
child_document = factories.DocumentFactory(
|
||||
parent=parent_document,
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=child_document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.AUTHENTICATED,
|
||||
"link_role": models.LinkRoleChoices.READER, # This should be rejected
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_role" in response.json()
|
||||
error_message = response.json()["link_role"][0]
|
||||
assert (
|
||||
"Link role 'reader' is not allowed for link reach 'authenticated'"
|
||||
in error_message
|
||||
)
|
||||
assert "Allowed roles: editor" in error_message
|
||||
|
||||
@@ -69,6 +69,7 @@ def test_api_documents_list_format():
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": True,
|
||||
@@ -427,3 +428,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
|
||||
assert result["is_favorite"] is True
|
||||
else:
|
||||
assert result["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_throttling(settings):
|
||||
"""Test api documents throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "2/minute"
|
||||
client = APIClient()
|
||||
for _i in range(2):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope document", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
|
||||
|
||||
@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_masked
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they marked as masked.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
masked_documents = factories.DocumentFactory.create_batch(
|
||||
3, users=[user], masked_by=[user]
|
||||
)
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are marked as masked by the current user
|
||||
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||
for result in results:
|
||||
assert result["id"] in masked_documents_ids
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they didn't mark as masked.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
masked_documents = factories.DocumentFactory.create_batch(
|
||||
3, users=[user], masked_by=[user]
|
||||
)
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 4
|
||||
|
||||
# Ensure all results are not marked as masked by the current user
|
||||
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||
for result in results:
|
||||
assert result["id"] not in masked_documents_ids
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_invalid():
|
||||
"""Filtering with an invalid `is_masked` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
|
||||
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Test mask 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_mask_anonymous_user(method, reach):
|
||||
"""Anonymous users should not be able to mask/unmask documents."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = getattr(APIClient(), method)(
|
||||
f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.LinkTrace.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mask a document to which they have access."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking the document without a link trace
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "User never accessed this document before."}
|
||||
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||
|
||||
models.LinkTrace.objects.create(document=document, user=user)
|
||||
# Mask document
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_forbidden():
|
||||
"""
|
||||
Authenticated users should no be allowed to mask a document
|
||||
to which they don't have access.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Try masking
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.LinkTrace.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_mask_authenticated_post_already_masked_allowed(reach, has_role):
|
||||
"""POST should not create duplicate link trace if already marked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_already_masked_forbidden():
|
||||
"""POST should not create duplicate masks if already marked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.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_mask_authenticated_post_unmasked_allowed(reach, has_role):
|
||||
"""POST should not create duplicate link trace if unmasked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_unmasked_forbidden():
|
||||
"""POST should not create duplicate masks if unmasked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to unmask a document using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert response.content == b"" # No body
|
||||
assert response.text == "" # Empty decoded text
|
||||
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_delete_forbidden():
|
||||
"""
|
||||
Authenticated users should not be allowed to unmask a document if
|
||||
they don't have access to it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
|
||||
"""DELETE should be idempotent if the document is not masked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try unmasking the document without a link trace
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "User never accessed this document before."}
|
||||
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already not masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
|
||||
"""DELETE should be idempotent if the document is not masked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Try to unmask when no entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert (
|
||||
models.LinkTrace.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_mask_authenticated_post_unmark_then_mark_again_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""A user should be able to mask, unmask, and mask a document again."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||
|
||||
# Mask document
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(url)
|
||||
assert response.status_code == 204
|
||||
assert response.content == b"" # No body
|
||||
assert response.text == "" # Empty decoded text
|
||||
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||
|
||||
# Mask document again
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
@@ -36,7 +36,9 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": document.link_role in ["commenter", "editor"],
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
@@ -45,10 +47,11 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -68,6 +71,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
@@ -110,8 +114,10 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
@@ -121,6 +127,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**links_definition
|
||||
),
|
||||
"mask": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -140,6 +147,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
@@ -214,18 +222,21 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"children_create": document.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": document.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -246,6 +257,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
@@ -295,8 +307,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"children_create": grand_parent.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -305,6 +319,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**links_definition
|
||||
),
|
||||
"mask": True,
|
||||
"move": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
@@ -325,6 +340,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
@@ -438,6 +454,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"content": document.content,
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
@@ -481,16 +498,18 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": access.role in ["administrator", "owner"],
|
||||
"accesses_view": True,
|
||||
"ai_transform": access.role != "reader",
|
||||
"ai_translate": access.role != "reader",
|
||||
"attachment_upload": access.role != "reader",
|
||||
"can_edit": access.role != "reader",
|
||||
"children_create": access.role != "reader",
|
||||
"ai_transform": access.role not in ["reader", "commenter"],
|
||||
"ai_translate": access.role not in ["reader", "commenter"],
|
||||
"attachment_upload": access.role not in ["reader", "commenter"],
|
||||
"can_edit": access.role not in ["reader", "commenter"],
|
||||
"children_create": access.role not in ["reader", "commenter"],
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": access.role != "reader",
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": access.role == "owner",
|
||||
"content": True,
|
||||
"destroy": access.role in ["administrator", "owner"],
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
"invite_owner": access.role == "owner",
|
||||
@@ -498,14 +517,15 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**link_definition
|
||||
),
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
"partial_update": access.role != "reader",
|
||||
"partial_update": access.role not in ["reader", "commenter"],
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access.role != "reader",
|
||||
"update": access.role not in ["reader", "commenter"],
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
@@ -518,6 +538,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
@@ -673,6 +694,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
@@ -739,6 +761,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
@@ -805,6 +828,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
|
||||
425
src/backend/core/tests/documents/test_api_documents_search.py
Normal file
425
src/backend/core/tests/documents/test_api_documents_search.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
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),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
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),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
1226
src/backend/core/tests/documents/test_api_documents_threads.py
Normal file
1226
src/backend/core/tests/documents/test_api_documents_threads.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,11 +48,11 @@ def test_api_documents_trashbin_format():
|
||||
|
||||
other_users = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
deleted_at=timezone.now(),
|
||||
users=factories.UserFactory.create_batch(2),
|
||||
favorited_by=[user, *other_users],
|
||||
link_traces=other_users,
|
||||
)
|
||||
document.soft_delete()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
@@ -70,38 +70,41 @@ def test_api_documents_trashbin_format():
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"can_edit": True,
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": True,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"can_edit": False,
|
||||
"children_create": False,
|
||||
"children_list": False,
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"comment": False,
|
||||
"content": False,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"mask": False,
|
||||
"media_auth": False,
|
||||
"media_check": False,
|
||||
"move": False, # Can't move a deleted document
|
||||
"partial_update": True,
|
||||
"partial_update": False,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
@@ -111,6 +114,7 @@ def test_api_documents_trashbin_format():
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -163,10 +167,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
|
||||
|
||||
with django_assert_num_queries(10):
|
||||
with django_assert_num_queries(11):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -205,10 +209,10 @@ def test_api_documents_trashbin_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(8):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -290,3 +294,29 @@ def test_api_documents_trashbin_distinct():
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_api_documents_trashbin_empty_queryset_bug():
|
||||
"""
|
||||
Test that users with no owner role don't see documents.
|
||||
"""
|
||||
# Create a new user with no owner access to any document
|
||||
new_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(new_user)
|
||||
|
||||
# Create some deleted documents owned by other users
|
||||
other_user = factories.UserFactory()
|
||||
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item1.soft_delete()
|
||||
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item2.soft_delete()
|
||||
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item3.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 0
|
||||
assert len(content["results"]) == 0
|
||||
|
||||
@@ -50,6 +50,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -73,6 +74,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -96,6 +98,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(sibling1.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": sibling1.excerpt,
|
||||
"id": str(sibling1.id),
|
||||
"is_favorite": False,
|
||||
@@ -119,6 +122,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(sibling2.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": sibling2.excerpt,
|
||||
"id": str(sibling2.id),
|
||||
"is_favorite": False,
|
||||
@@ -138,6 +142,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 1,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -210,6 +215,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 5,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -233,6 +239,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -260,6 +267,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
),
|
||||
"creator": str(document_sibling.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -281,6 +289,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -306,6 +315,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
),
|
||||
"creator": str(parent_sibling.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -327,6 +337,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_parent.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -406,6 +417,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -427,6 +439,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -450,6 +463,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(sibling.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -469,6 +483,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 1,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -546,6 +561,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 5,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -569,6 +585,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -596,6 +613,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
),
|
||||
"creator": str(document_sibling.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -617,6 +635,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -642,6 +661,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
),
|
||||
"creator": str(parent_sibling.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -663,6 +683,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_parent.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -744,6 +765,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -765,6 +787,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -788,6 +811,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(sibling.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -807,6 +831,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 1,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -888,6 +913,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 5,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -911,6 +937,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -938,6 +965,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
),
|
||||
"creator": str(document_sibling.creator.id),
|
||||
"depth": 4,
|
||||
"deleted_at": None,
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -959,6 +987,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -984,6 +1013,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
),
|
||||
"creator": str(parent_sibling.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -1005,6 +1035,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_parent.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -1094,6 +1125,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
),
|
||||
"creator": str(child.creator.id),
|
||||
"depth": 3,
|
||||
"deleted_at": None,
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
@@ -1115,6 +1147,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
@@ -1138,6 +1171,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(sibling.creator.id),
|
||||
"depth": 2,
|
||||
"deleted_at": None,
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
@@ -1157,6 +1191,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(parent.creator.id),
|
||||
"depth": 1,
|
||||
"deleted_at": None,
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
@@ -1170,3 +1205,56 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_tree_list_deleted_document():
|
||||
"""
|
||||
Tree of a deleted document should only be accessible to the owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(link_reach="public")
|
||||
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
|
||||
factories.DocumentFactory(link_reach="public", parent=document)
|
||||
|
||||
document.soft_delete()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_tree_list_deleted_document_owner(django_assert_num_queries):
|
||||
"""
|
||||
Tree of a deleted document should only be accessible to the owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(link_reach="public", users=[(user, "owner")])
|
||||
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
child.refresh_from_db()
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["id"] == str(document.id)
|
||||
assert content["deleted_at"] == document.deleted_at.isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
assert len(content["children"]) == 1
|
||||
assert content["children"][0]["id"] == str(child.id)
|
||||
assert content["children"][0][
|
||||
"deleted_at"
|
||||
] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Test user light serializer."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.api.serializers import UserLightSerializer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_user_light_serializer():
|
||||
"""Test user light serializer."""
|
||||
user = factories.UserFactory(
|
||||
email="test@test.com",
|
||||
full_name="John Doe",
|
||||
short_name="John",
|
||||
)
|
||||
serializer = UserLightSerializer(user)
|
||||
assert serializer.data["full_name"] == "John Doe"
|
||||
assert serializer.data["short_name"] == "John"
|
||||
|
||||
|
||||
def test_user_light_serializer_no_full_name():
|
||||
"""Test user light serializer without full name."""
|
||||
user = factories.UserFactory(
|
||||
email="test_foo@test.com",
|
||||
full_name=None,
|
||||
short_name="John",
|
||||
)
|
||||
serializer = UserLightSerializer(user)
|
||||
assert serializer.data["full_name"] == "test_foo"
|
||||
assert serializer.data["short_name"] == "John"
|
||||
|
||||
|
||||
def test_user_light_serializer_no_short_name():
|
||||
"""Test user light serializer without short name."""
|
||||
user = factories.UserFactory(
|
||||
email="test_foo@test.com",
|
||||
full_name=None,
|
||||
short_name=None,
|
||||
)
|
||||
serializer = UserLightSerializer(user)
|
||||
assert serializer.data["full_name"] == "test_foo"
|
||||
assert serializer.data["short_name"] == "test_foo"
|
||||
@@ -1,775 +0,0 @@
|
||||
"""
|
||||
Test template accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_template_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list template accesses."""
|
||||
template = factories.TemplateFactory()
|
||||
factories.UserTemplateAccessFactory.create_batch(2, template=template)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list template accesses for a template
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
factories.UserTemplateAccessFactory.create_batch(3, template=template)
|
||||
|
||||
# Accesses for other templates to which the user is related should not be listed either
|
||||
other_access = factories.UserTemplateAccessFactory(user=user)
|
||||
factories.UserTemplateAccessFactory(template=other_access.template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list template accesses for a template
|
||||
to which they are directly related, whatever their role in the template.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
user_access = None
|
||||
if via == USER:
|
||||
user_access = models.TemplateAccess.objects.create(
|
||||
template=template,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.values),
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
user_access = models.TemplateAccess.objects.create(
|
||||
template=template,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.values),
|
||||
)
|
||||
|
||||
access1 = factories.TeamTemplateAccessFactory(template=template)
|
||||
access2 = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
# Accesses for other templates to which the user is related should not be listed either
|
||||
other_access = factories.UserTemplateAccessFactory(user=user)
|
||||
factories.UserTemplateAccessFactory(template=other_access.template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content) == 3
|
||||
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": str(user.id) if via == "user" else None,
|
||||
"team": "lasuite" if via == "team" else "",
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": None,
|
||||
"team": access1.team,
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_template_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a template access.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a template access for
|
||||
a template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Accesses related to another template should be excluded even if the user is related to it
|
||||
for access in [
|
||||
factories.UserTemplateAccessFactory(),
|
||||
factories.UserTemplateAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"detail": "No TemplateAccess matches the given query."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
A user who is related to a template should be allowed to retrieve the
|
||||
associated template user accesses.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": str(access.user.id),
|
||||
"team": "",
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template access."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_template_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template access for a template to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is a direct administrator in a template should be allowed to update a user
|
||||
access for this template, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "editor", "reader"]),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of an "owner" for this template.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=other_user, role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of another user to grant template ownership.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.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"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an owner in a template should be allowed to update
|
||||
a user access for this template whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
|
||||
"""
|
||||
A user who is owner of a template should be allowed to update
|
||||
their own user access provided there are other owners in the template.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
else:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "editor", "reader"])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.UserTemplateAccessFactory(template=template, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_template_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a template access."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_template_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template in which they are a simple editor or reader.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_administrators_except_owners(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a template should be allowed to delete an access
|
||||
from the template provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, role=random.choice(["reader", "editor", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users who are administrators in a template should not be allowed to delete an ownership
|
||||
access from the template.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template, role="owner")
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users should be able to delete the template access of another user
|
||||
for a template of which they are owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a template
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Test template accesses create API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_template_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create template accesses."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"template": str(template.id),
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TemplateAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_template_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create template accesses for a template to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for new_role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
|
||||
"""
|
||||
Administrators of a template should be able to create template accesses
|
||||
except for the "owner" role.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a template can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
"id": str(new_template_access.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Owners of a template should be able to create template accesses whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"id": str(new_template_access.id),
|
||||
"user": str(other_user.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
}
|
||||
@@ -42,7 +42,5 @@ def test_api_templates_create_authenticated():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
template = Template.objects.get()
|
||||
assert template.title == "my template"
|
||||
assert template.accesses.filter(role="owner", user=user).exists()
|
||||
assert response.status_code == 405
|
||||
assert not Template.objects.exists()
|
||||
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_templates_delete_authenticated_unrelated():
|
||||
def test_api_templates_delete_not_implemented():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template to which they are not
|
||||
related.
|
||||
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
|
||||
client.force_login(user)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(is_public=is_public)
|
||||
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403 if is_public else 404
|
||||
assert response.status_code == 405
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_delete_authenticated_member_or_administrator(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template for which they are
|
||||
only a member or administrator.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to delete a template they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Template.objects.exists() is False
|
||||
|
||||
@@ -218,3 +218,20 @@ def test_api_templates_list_order_param():
|
||||
assert response_template_ids == templates_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_template_throttling(settings):
|
||||
"""Test api template throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
|
||||
client = APIClient()
|
||||
for _i in range(2):
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope template", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user