mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
414 Commits
feature/do
...
feat/e2e-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0144044c55 | ||
|
|
a6da37e231 | ||
|
|
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 | ||
|
|
cd6e0ef9e1 | ||
|
|
02acc7233f | ||
|
|
1c71e830a2 | ||
|
|
ac0c16a44a | ||
|
|
ca09f9a158 | ||
|
|
d12b608db9 | ||
|
|
08a0eb59c8 | ||
|
|
0afc50fb93 | ||
|
|
c48a4309c1 | ||
|
|
a212417fb8 | ||
|
|
500d4ea5ac | ||
|
|
8a057b9c39 | ||
|
|
6a12ac560e | ||
|
|
2e6cb109ef | ||
|
|
70635136cb | ||
|
|
52a8dd0b5c | ||
|
|
8a3dfe0252 | ||
|
|
1110ec92d5 | ||
|
|
1d01f6512e | ||
|
|
cd366213ca | ||
|
|
d15285d385 | ||
|
|
377d4e8971 | ||
|
|
70f0c7052c | ||
|
|
ca2e02806a | ||
|
|
33bd5ef116 | ||
|
|
7abe1c9eb4 | ||
|
|
95838e332c | ||
|
|
82f2cb59e6 | ||
|
|
44909faa67 | ||
|
|
1c5270e301 | ||
|
|
6af8d78ede | ||
|
|
304b3be273 | ||
|
|
17ece3b715 | ||
|
|
510d6c3ff1 | ||
|
|
cab7771b82 | ||
|
|
93d9dec068 | ||
|
|
adb15dedb8 | ||
|
|
6ece3264d6 | ||
|
|
2a3b31fcff | ||
|
|
9a64ebc1e9 | ||
|
|
cb2ecfcea3 | ||
|
|
13696ffbd7 | ||
|
|
40ed2d2e22 | ||
|
|
ecb20f6f77 | ||
|
|
7bc060988d | ||
|
|
122e510ff4 | ||
|
|
f717a39109 | ||
|
|
04b8400766 | ||
|
|
d232654c55 | ||
|
|
d0eb2275e5 | ||
|
|
50faf766c8 | ||
|
|
433cead0ac | ||
|
|
d12c637dad | ||
|
|
184b5c015b | ||
|
|
1ab237af3b | ||
|
|
f782a0236b | ||
|
|
c1fc1bd52f | ||
|
|
1c34305393 | ||
|
|
611ba496d2 | ||
|
|
0a9a583a67 | ||
|
|
8f67e382ba | ||
|
|
18d46acd75 | ||
|
|
fae024229e | ||
|
|
df2b953e53 | ||
|
|
a7c91f9443 | ||
|
|
0a5887c162 | ||
|
|
26c7af0dbf | ||
|
|
0499aec624 | ||
|
|
21624e9224 | ||
|
|
b0a9ce0938 | ||
|
|
e256017628 | ||
|
|
50ce604ade | ||
|
|
55979e4370 | ||
|
|
9a8f952210 | ||
|
|
118804e810 | ||
|
|
651f2d1d75 | ||
|
|
b96de36382 | ||
|
|
65b6701708 | ||
|
|
0be366b7b6 | ||
|
|
78a6772bab | ||
|
|
fde520a6f3 | ||
|
|
cef2d274fc | ||
|
|
a9db392a61 | ||
|
|
186ae952f5 | ||
|
|
f3c9c41b86 | ||
|
|
58bf5071c2 | ||
|
|
e148c237f1 | ||
|
|
e82e6a1fcf | ||
|
|
fc1678d0c2 | ||
|
|
2b2e81f042 | ||
|
|
c8ae2f6549 | ||
|
|
1d741871d7 |
6
.github/ISSUE_TEMPLATE.md
vendored
6
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +0,0 @@
|
|||||||
<!---
|
|
||||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
|
||||||
|
|
||||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
|
||||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
|
||||||
-->
|
|
||||||
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -6,6 +6,10 @@ labels: ["bug", "triage"]
|
|||||||
|
|
||||||
## Bug Report
|
## Bug Report
|
||||||
|
|
||||||
|
**Before you file your issue**
|
||||||
|
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
|
||||||
|
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
|
||||||
|
|
||||||
**Problematic behavior**
|
**Problematic behavior**
|
||||||
A clear and concise description of the behavior.
|
A clear and concise description of the behavior.
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/crowdin_upload.yml
vendored
3
.github/workflows/crowdin_upload.yml
vendored
@@ -23,9 +23,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
# Backend i18n
|
# Backend i18n
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13.3"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
- name: Upgrade pip and setuptools
|
- name: Upgrade pip and setuptools
|
||||||
run: pip install --upgrade pip setuptools
|
run: pip install --upgrade pip setuptools
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
|
|||||||
24
.github/workflows/docker-hub.yml
vendored
24
.github/workflows/docker-hub.yml
vendored
@@ -31,8 +31,11 @@ jobs:
|
|||||||
images: lasuite/impress-backend
|
images: lasuite/impress-backend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
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
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
@@ -46,7 +49,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -64,8 +67,11 @@ jobs:
|
|||||||
images: lasuite/impress-frontend
|
images: lasuite/impress-frontend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
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
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
@@ -82,7 +88,7 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
PUBLISH_AS_MIT=false
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ jobs:
|
|||||||
images: lasuite/impress-y-provider
|
images: lasuite/impress-y-provider
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
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
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
@@ -116,7 +122,7 @@ jobs:
|
|||||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -125,7 +131,7 @@ jobs:
|
|||||||
- build-and-push-frontend
|
- build-and-push-frontend
|
||||||
- build-and-push-backend
|
- build-and-push-backend
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
id: notify
|
id: notify
|
||||||
|
|||||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -e
|
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")
|
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
|
for env in $environments; do
|
||||||
echo "################### $env lint ###################"
|
echo "################### $env lint ###################"
|
||||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
helmfile -e $env lint -f $HELMFILE || exit 1
|
||||||
echo -e "\n"
|
echo -e "\n"
|
||||||
done
|
done
|
||||||
|
|||||||
57
.github/workflows/impress-frontend.yml
vendored
57
.github/workflows/impress-frontend.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- 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
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
test-e2e-other-browser:
|
test-e2e-other-browser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test-e2e-chromium
|
needs: test-e2e-chromium
|
||||||
timeout-minutes: 20
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- 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
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||||
@@ -136,3 +136,54 @@ jobs:
|
|||||||
name: playwright-other-report
|
name: playwright-other-report
|
||||||
path: src/frontend/apps/e2e/report/
|
path: src/frontend/apps/e2e/report/
|
||||||
retention-days: 7
|
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
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
run: git log
|
run: git log
|
||||||
- name: Enforce absence of print statements in code
|
- name: Enforce absence of print statements in code
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||||
- name: Check absence of fixup commits
|
- name: Check absence of fixup commits
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git log | grep 'fixup!'
|
! git log | grep 'fixup!'
|
||||||
- name: Install gitlint
|
- name: Install gitlint
|
||||||
|
if: always()
|
||||||
run: pip install --user requests gitlint
|
run: pip install --user requests gitlint
|
||||||
- name: Lint commit messages added to main
|
- name: Lint commit messages added to main
|
||||||
|
if: always()
|
||||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||||
|
|
||||||
check-changelog:
|
check-changelog:
|
||||||
@@ -42,7 +46,7 @@ jobs:
|
|||||||
github.event_name == 'pull_request'
|
github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -52,7 +56,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -66,7 +70,7 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Install codespell
|
- name: Install codespell
|
||||||
run: pip install --user codespell
|
run: pip install --user codespell
|
||||||
- name: Check for typos
|
- name: Check for typos
|
||||||
@@ -75,6 +79,7 @@ jobs:
|
|||||||
--check-filenames \
|
--check-filenames \
|
||||||
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||||
--skip "./git/" \
|
--skip "./git/" \
|
||||||
|
--skip "**/*.pdf" \
|
||||||
--skip "**/*.po" \
|
--skip "**/*.po" \
|
||||||
--skip "**/*.pot" \
|
--skip "**/*.pot" \
|
||||||
--skip "**/*.json" \
|
--skip "**/*.json" \
|
||||||
@@ -87,11 +92,12 @@ jobs:
|
|||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13.3"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
- name: Upgrade pip and setuptools
|
- name: Upgrade pip and setuptools
|
||||||
run: pip install --upgrade pip setuptools
|
run: pip install --upgrade pip setuptools
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
@@ -184,9 +190,10 @@ jobs:
|
|||||||
mc version enable impress/impress-media-storage"
|
mc version enable impress/impress-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13.3"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,8 +40,7 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
env.d/development/*
|
env.d/development/*.local
|
||||||
!env.d/development/*.dist
|
|
||||||
env.d/terraform
|
env.d/terraform
|
||||||
|
|
||||||
# npm
|
# npm
|
||||||
@@ -76,3 +75,6 @@ db.sqlite3
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
.devcontainer
|
.devcontainer
|
||||||
|
|
||||||
|
# Cursor rules
|
||||||
|
.cursorrules
|
||||||
|
|||||||
390
CHANGELOG.md
390
CHANGELOG.md
@@ -1,5 +1,3 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||||
@@ -10,16 +8,299 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add customization for translations #857
|
- ✨ Add comments feature to the editor #1330
|
||||||
- 📝(project) add troubleshoot doc #1066
|
- ✨(backend) Comments on text editor #1330
|
||||||
- 📝(project) add system-requirement doc #1066
|
|
||||||
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
|
|
||||||
- (doc) add documentation to install with compose #855
|
|
||||||
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
|
|
||||||
- ✨Ask for access #1081
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(sw) stop to cache external resources likes videos #1655
|
||||||
|
- 💥(frontend) upgrade to ui-kit v2
|
||||||
|
- ⚡️(frontend) improve perf on upload and table of contents #1662
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) improve share modal button accessibility #1626
|
||||||
|
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||||
|
- 🐛(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
|
||||||
|
- ✅(e2e) add test to compare generated PDF against reference template #1648
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(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
|
||||||
|
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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) multi-pages #701
|
||||||
|
- ✨(frontend) Duplicate a doc #1078
|
||||||
|
- ✨Ask for access #1081
|
||||||
|
- ✨(frontend) add customization for translations #857
|
||||||
|
- ✨(backend) add ancestors links definitions to document abilities #846
|
||||||
|
- ✨(backend) include ancestors accesses on document accesses list view #846
|
||||||
|
- ✨(backend) add ancestors links reach and role to document API #846
|
||||||
|
- 📝(project) add troubleshoot doc #1066
|
||||||
|
- 📝(project) add system-requirement doc #1066
|
||||||
|
- 🔧(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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(backend) stop requiring owner for non-root documents #846
|
||||||
|
- ♻️(backend) simplify roles by ranking them and return only the max role #846
|
||||||
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
|
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
|
||||||
- 🧑💻(docker) add .next to .dockerignore #1055
|
- 🧑💻(docker) add .next to .dockerignore #1055
|
||||||
- 🧑💻(docker) handle frontend development images with docker compose #1033
|
- 🧑💻(docker) handle frontend development images with docker compose #1033
|
||||||
@@ -28,19 +309,18 @@ and this project adheres to
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) fix link definition select options linked to ancestors #846
|
||||||
- 🐛(frontend) table of content disappearing #982
|
- 🐛(frontend) table of content disappearing #982
|
||||||
- 🐛(frontend) fix multiple EmojiPicker #1012
|
- 🐛(frontend) fix multiple EmojiPicker #1012
|
||||||
- 🐛(frontend) fix meta title #1017
|
- 🐛(frontend) fix meta title #1017
|
||||||
- 🔧(git) set LF line endings for all text files #1032
|
- 🔧(git) set LF line endings for all text files #1032
|
||||||
- 📝(docs) minor fixes to docs/env.md
|
- 📝(docs) minor fixes to docs/env.md
|
||||||
- ✨(backend) support `_FILE` environment variables for secrets #912
|
- ✨support `_FILE` environment variables for secrets #912
|
||||||
- ✨(frontend) support `_FILE` environment variables for secrets #912
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- 🔥(frontend) remove Beta from logo #1095
|
- 🔥(frontend) remove Beta from logo #1095
|
||||||
|
|
||||||
|
|
||||||
## [3.3.0] - 2025-05-06
|
## [3.3.0] - 2025-05-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -66,13 +346,13 @@ and this project adheres to
|
|||||||
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- 🔥(back) remove footer endpoint #948
|
- 🔥(back) remove footer endpoint #948
|
||||||
|
|
||||||
|
|
||||||
## [3.2.1] - 2025-05-06
|
## [3.2.1] - 2025-05-06
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
@@ -80,7 +360,6 @@ and this project adheres to
|
|||||||
- 🐛(frontend) fix list copy paste #943
|
- 🐛(frontend) fix list copy paste #943
|
||||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
|
|
||||||
|
|
||||||
## [3.2.0] - 2025-05-05
|
## [3.2.0] - 2025-05-05
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -91,7 +370,7 @@ and this project adheres to
|
|||||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||||
- 🌐(i18n) activate chinese and spanish languages #884
|
- 🌐(i18n) activate chinese and spanish languages #884
|
||||||
- 🔧(backend) allow overwriting the data directory #893
|
- 🔧(backend) allow overwriting the data directory #893
|
||||||
- ➕(backend) add `django-lasuite` dependency #839
|
- ➕(backend) add `django-lasuite` dependency #839
|
||||||
- ✨(frontend) advanced table features #908
|
- ✨(frontend) advanced table features #908
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
@@ -142,7 +421,6 @@ and this project adheres to
|
|||||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||||
- 🔒️(back) restrict access to document accesses #801
|
- 🔒️(back) restrict access to document accesses #801
|
||||||
|
|
||||||
|
|
||||||
## [2.6.0] - 2025-03-21
|
## [2.6.0] - 2025-03-21
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -161,7 +439,6 @@ and this project adheres to
|
|||||||
- 🔒️(back) throttle user list endpoint #636
|
- 🔒️(back) throttle user list endpoint #636
|
||||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||||
|
|
||||||
|
|
||||||
## [2.5.0] - 2025-03-18
|
## [2.5.0] - 2025-03-18
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -184,15 +461,14 @@ and this project adheres to
|
|||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(frontend) SVG export #706
|
- 🐛(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
|
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||||
- 🐛(backend) refactor to fix filtering on children
|
- 🐛(backend) refactor to fix filtering on children
|
||||||
and descendants views #695
|
and descendants views #695
|
||||||
- 🐛(action) fix notify-argocd workflow #713
|
- 🐛(action) fix notify-argocd workflow #713
|
||||||
- 🚨(helm) fix helmfile lint #736
|
- 🚨(helm) fix helmfile lint #736
|
||||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||||
|
|
||||||
|
|
||||||
## [2.4.0] - 2025-03-06
|
## [2.4.0] - 2025-03-06
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -207,7 +483,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🐛(frontend) fix collaboration error #684
|
- 🐛(frontend) fix collaboration error #684
|
||||||
|
|
||||||
|
|
||||||
## [2.3.0] - 2025-03-03
|
## [2.3.0] - 2025-03-03
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -623,33 +898,44 @@ and this project adheres to
|
|||||||
- ✨(frontend) Coming Soon page (#67)
|
- ✨(frontend) Coming Soon page (#67)
|
||||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||||
|
|
||||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main
|
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
|
||||||
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
|
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||||
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
|
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||||
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
|
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
|
||||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
|
||||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
[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
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@@ -7,8 +7,7 @@ FROM python:3.13.3-alpine AS base
|
|||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && apk upgrade --no-cache
|
||||||
apk upgrade
|
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
@@ -45,7 +44,7 @@ FROM base AS link-collector
|
|||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Install pango & rdfind
|
# Install pango & rdfind
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
pango \
|
pango \
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ FROM base AS core
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install required system libs
|
# Install required system libs
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
cairo \
|
cairo \
|
||||||
file \
|
file \
|
||||||
font-noto \
|
font-noto \
|
||||||
@@ -95,6 +94,14 @@ RUN chmod g=u /etc/passwd
|
|||||||
# Copy installed python dependencies
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /install /usr/local
|
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 impress application (see .dockerignore)
|
||||||
COPY ./src/backend /app/
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
@@ -117,7 +124,7 @@ FROM core AS backend-development
|
|||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
# Uninstall impress and re-install it in editable mode along with development
|
# Uninstall impress and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
116
Makefile
116
Makefile
@@ -35,9 +35,13 @@ DB_PORT = 5432
|
|||||||
|
|
||||||
# -- Docker
|
# -- Docker
|
||||||
# Get the current user ID to use for docker run and docker exec commands
|
# Get the current user ID to use for docker run and docker exec commands
|
||||||
DOCKER_UID = $(shell id -u)
|
ifeq ($(OS),Windows_NT)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_USER := 0:0 # run containers as root on Windows
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
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 = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
@@ -48,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -67,18 +71,18 @@ data/static:
|
|||||||
|
|
||||||
# -- Project
|
# -- Project
|
||||||
|
|
||||||
create-env-files: ## Copy the dist env files to env files
|
create-env-local-files: ## create env.local files in env.d/development
|
||||||
create-env-files: \
|
create-env-local-files:
|
||||||
env.d/development/common \
|
@touch env.d/development/crowdin.local
|
||||||
env.d/development/crowdin \
|
@touch env.d/development/common.local
|
||||||
env.d/development/postgresql \
|
@touch env.d/development/postgresql.local
|
||||||
env.d/development/kc_postgresql
|
@touch env.d/development/kc_postgresql.local
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-local-files
|
||||||
|
|
||||||
pre-bootstrap: \
|
pre-bootstrap: \
|
||||||
data/media \
|
data/media \
|
||||||
data/static \
|
data/static \
|
||||||
create-env-files
|
create-env-local-files
|
||||||
.PHONY: pre-bootstrap
|
.PHONY: pre-bootstrap
|
||||||
|
|
||||||
post-bootstrap: \
|
post-bootstrap: \
|
||||||
@@ -89,13 +93,77 @@ post-bootstrap: \
|
|||||||
mails-build
|
mails-build
|
||||||
.PHONY: post-bootstrap
|
.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: \
|
bootstrap: \
|
||||||
|
pre-beautiful-bootstrap \
|
||||||
pre-bootstrap \
|
pre-bootstrap \
|
||||||
build \
|
build \
|
||||||
post-bootstrap \
|
post-bootstrap \
|
||||||
run
|
run \
|
||||||
|
post-beautiful-bootstrap
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||||
@@ -258,20 +326,6 @@ resetdb: ## flush database and create a superuser "admin"
|
|||||||
@${MAKE} superuser
|
@${MAKE} superuser
|
||||||
.PHONY: resetdb
|
.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
|
crowdin-download: ## Download translated message from crowdin
|
||||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||||
.PHONY: crowdin-download
|
.PHONY: crowdin-download
|
||||||
@@ -352,6 +406,10 @@ run-frontend-development: ## Run the frontend in development mode
|
|||||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||||
.PHONY: run-frontend-development
|
.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
|
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||||
cd $(PATH_FRONT) && yarn i18n:extract
|
cd $(PATH_FRONT) && yarn i18n:extract
|
||||||
.PHONY: frontend-i18n-extract
|
.PHONY: frontend-i18n-extract
|
||||||
@@ -382,6 +440,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/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/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/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)
|
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
.PHONY: bump-packages-version
|
.PHONY: bump-packages-version
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -34,8 +34,6 @@ Docs, where your notes can become knowledge through live collaboration.
|
|||||||
## Why use Docs ❓
|
## Why use Docs ❓
|
||||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||||
|
|
||||||
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
|
|
||||||
|
|
||||||
### Write
|
### Write
|
||||||
* 😌 Get simple, accessible online editing for your team.
|
* 😌 Get simple, accessible online editing for your team.
|
||||||
* 💅 Create clean documents with beautiful formatting options.
|
* 💅 Create clean documents with beautiful formatting options.
|
||||||
@@ -51,13 +49,24 @@ It offers a scalable and secure alternative to tools such as Google Docs, Notion
|
|||||||
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||||
|
|
||||||
### Self-host
|
### 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 🔧
|
## Getting started 🔧
|
||||||
|
|
||||||
@@ -132,6 +141,12 @@ To start all the services, except the frontend container, you can use the follow
|
|||||||
$ make run-backend
|
$ make run-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To execute frontend tests & linting only
|
||||||
|
```shellscript
|
||||||
|
$ make frontend-test
|
||||||
|
$ make frontend-lint
|
||||||
|
```
|
||||||
|
|
||||||
**Adding content**
|
**Adding content**
|
||||||
|
|
||||||
You can create a basic demo site by running this command:
|
You can create a basic demo site by running this command:
|
||||||
|
|||||||
@@ -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-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 .'))
|
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ function _set_user() {
|
|||||||
# options: docker compose command options
|
# options: docker compose command options
|
||||||
# ARGS : docker compose command arguments
|
# ARGS : docker compose command arguments
|
||||||
function _docker_compose() {
|
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}'"
|
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
18
compose.yml
18
compose.yml
@@ -10,6 +10,7 @@ services:
|
|||||||
retries: 300
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "15432:5432"
|
- "15432:5432"
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "8071:8000"
|
- "8071:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -91,7 +94,9 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
@@ -135,6 +140,7 @@ services:
|
|||||||
- ".:/app"
|
- ".:/app"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/crowdin
|
- env.d/development/crowdin
|
||||||
|
- env.d/development/crowdin.local
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
@@ -156,6 +162,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -174,24 +181,23 @@ services:
|
|||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/kc_postgresql
|
- env.d/development/kc_postgresql
|
||||||
|
- env.d/development/kc_postgresql.local
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:20.0.1
|
image: quay.io/keycloak/keycloak:26.3
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
- --features=preview
|
- --features=preview
|
||||||
- --import-realm
|
- --import-realm
|
||||||
- --proxy=edge
|
- --hostname=http://localhost:8083
|
||||||
- --hostname-url=http://localhost:8083
|
|
||||||
- --hostname-admin-url=http://localhost:8083/
|
|
||||||
- --hostname-strict=false
|
- --hostname-strict=false
|
||||||
- --hostname-strict-https=false
|
|
||||||
- --health-enabled=true
|
- --health-enabled=true
|
||||||
- --metrics-enabled=true
|
- --metrics-enabled=true
|
||||||
healthcheck:
|
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
|
interval: 1s
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
retries: 300
|
retries: 300
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"oauth2DeviceCodeLifespan": 600,
|
"oauth2DeviceCodeLifespan": 600,
|
||||||
"oauth2DevicePollingInterval": 5,
|
"oauth2DevicePollingInterval": 5,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sslRequired": "external",
|
"sslRequired": "none",
|
||||||
"registrationAllowed": true,
|
"registrationAllowed": true,
|
||||||
"registrationEmailAsUsername": false,
|
"registrationEmailAsUsername": false,
|
||||||
"rememberMe": true,
|
"rememberMe": true,
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.test",
|
"email": "user.test@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.test",
|
"email": "user.test@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.test",
|
"email": "user.test@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -2270,7 +2270,7 @@
|
|||||||
"cibaInterval": "5",
|
"cibaInterval": "5",
|
||||||
"realmReusableOtpCode": "false"
|
"realmReusableOtpCode": "false"
|
||||||
},
|
},
|
||||||
"keycloakVersion": "20.0.1",
|
"keycloakVersion": "26.3.2",
|
||||||
"userManagedAccessAllowed": false,
|
"userManagedAccessAllowed": false,
|
||||||
"clientProfiles": {
|
"clientProfiles": {
|
||||||
"profiles": []
|
"profiles": []
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
# increase max upload size
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
# Disables server version feedback on pages and in headers
|
# Disables server version feedback on pages and in headers
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
@@ -68,7 +71,7 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /collaboration/api/ {
|
location /collaboration/api/ {
|
||||||
# Collaboration server
|
# Collaboration server
|
||||||
proxy_pass http://${YPROVIDER_HOST}:4444;
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -95,7 +98,7 @@ server {
|
|||||||
|
|
||||||
add_header Content-Security-Policy "default-src 'none'" always;
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /media-auth {
|
location /media-auth {
|
||||||
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
@@ -109,4 +112,4 @@ server {
|
|||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
proxy_set_header X-Original-Method $request_method;
|
proxy_set_header X-Original-Method $request_method;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
docs/env.md
203
docs/env.md
@@ -6,104 +6,104 @@ 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.
|
These are the environment variables you can set for the `impress-backend` container.
|
||||||
|
|
||||||
| Option | Description | default |
|
| Option | Description | default |
|
||||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
| 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_API_KEY | AI key to be used for AI Base url | |
|
||||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||||
| AI_FEATURE_ENABLED | Enable AI options | false |
|
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||||
| AI_MODEL | AI Model to use | |
|
| AI_MODEL | AI Model to use | |
|
||||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
| 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_BURST | Throttle rate for api on burst | 30/minute |
|
||||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
|
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||||
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
|
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||||
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
|
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||||
| AWS_S3_SECRET_ACCESS_KEY | access key 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 |
|
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||||
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
|
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||||
| COLLABORATION_API_URL | collaboration api host | |
|
| COLLABORATION_API_URL | Collaboration api host | |
|
||||||
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
|
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
|
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||||
| CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' |
|
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||||
| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | |
|
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
| DB_HOST | Host of the database | localhost |
|
||||||
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
| DB_NAME | Name of the database | impress |
|
||||||
| DB_HOST | host of the database | localhost |
|
| DB_PASSWORD | Password to authenticate with | pass |
|
||||||
| DB_NAME | name of the database | impress |
|
| DB_PORT | Port of the database | 5432 |
|
||||||
| DB_PASSWORD | password to authenticate with | pass |
|
| DB_USER | User to authenticate with | dinum |
|
||||||
| DB_PORT | port of the database | 5432 |
|
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||||
| DB_USER | user to authenticate with | dinum |
|
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||||
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
|
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
|
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||||
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
|
| 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_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
|
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||||
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
|
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||||
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
|
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||||
| DJANGO_EMAIL_HOST | host name of email | |
|
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||||
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
|
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||||
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
|
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||||
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
|
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||||
| DJANGO_EMAIL_PORT | port used to connect to email host | |
|
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||||
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
|
| DJANGO_SECRET_KEY | Secret key | |
|
||||||
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
|
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||||
| DJANGO_SECRET_KEY | secret key | |
|
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||||
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
|
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
| FRONTEND_THEME | Frontend theme to use | |
|
||||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
| LANGUAGE_CODE | Default language | en-us |
|
||||||
| FRONTEND_THEME | frontend theme to use | |
|
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
| LANGUAGE_CODE | default language | en-us |
|
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||||
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||||
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
|
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||||
| LOGOUT_REDIRECT_URL | logout redirect url | |
|
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
| MEDIA_BASE_URL | | |
|
||||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||||
| MEDIA_BASE_URL | | |
|
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||||
| OIDC_CREATE_USER | create used on OIDC | false |
|
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||||
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
|
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||||
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
|
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||||
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
|
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
| OIDC_USE_NONCE | Use nonce for OIDC | 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_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_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
| POSTHOG_KEY | Posthog key for analytics | |
|
||||||
| POSTHOG_KEY | posthog key for analytics | |
|
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||||
| REDIS_URL | cache url | redis://redis:6379/1 |
|
| SENTRY_DSN | Sentry host | |
|
||||||
| SENTRY_DSN | sentry host | |
|
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||||
| 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 |
|
||||||
| 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 |
|
||||||
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
|
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||||
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
|
||||||
|
|
||||||
## impress-frontend image
|
## impress-frontend image
|
||||||
|
|
||||||
@@ -135,10 +135,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 |
|
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||||
|
|
||||||
Packages with licences incompatible with the MIT licence:
|
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-docx-exporter`: [GPL](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-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.
|
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
|
timeout: 2s
|
||||||
retries: 300
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
- env.d/common
|
- env.d/common
|
||||||
environment:
|
environment:
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:8
|
image: redis:8
|
||||||
@@ -22,12 +22,12 @@ services:
|
|||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Production
|
- DJANGO_CONFIGURATION=Production
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/common
|
- env.d/common
|
||||||
- env.d/backend
|
- env.d/backend
|
||||||
- env.d/yprovider
|
- env.d/yprovider
|
||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "manage.py", "check"]
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -45,24 +45,24 @@ services:
|
|||||||
image: lasuite/impress-y-provider:latest
|
image: lasuite/impress-y-provider:latest
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/common
|
- env.d/common
|
||||||
- env.d/yprovider
|
- env.d/yprovider
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: lasuite/impress-frontend:latest
|
image: lasuite/impress-frontend:latest
|
||||||
user: "101"
|
user: "101"
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /docker-entrypoint.sh
|
- /docker-entrypoint.sh
|
||||||
command: ["nginx", "-g", "daemon off;"]
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/common
|
- env.d/common
|
||||||
# Uncomment and set your values if using our nginx proxy example
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
#environment:
|
#environment:
|
||||||
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
volumes:
|
volumes:
|
||||||
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir keycloak
|
mkdir keycloak
|
||||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
curl -o keycloak/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 keycloak/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/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2:. Update `env.d/` files
|
### Step 2:. Update `env.d/` files
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ services:
|
|||||||
timeout: 2s
|
timeout: 2s
|
||||||
retries: 300
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/kc_postgresql
|
- env.d/kc_postgresql
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.1.3
|
image: quay.io/keycloak/keycloak:26.1.3
|
||||||
command: ["start"]
|
command: ["start"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/kc_postgresql
|
- env.d/kc_postgresql
|
||||||
- env.d/keycloak
|
- env.d/keycloak
|
||||||
# Uncomment and set your values if using our nginx proxy example
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
# environment:
|
# 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
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
depends_on:
|
depends_on:
|
||||||
kc_postgresql::
|
kc_postgresql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
# Uncomment if using our nginx proxy example
|
# Uncomment if using our nginx proxy example
|
||||||
@@ -33,4 +33,4 @@ services:
|
|||||||
#
|
#
|
||||||
#networks:
|
#networks:
|
||||||
# proxy-tier:
|
# proxy-tier:
|
||||||
# external: true
|
# external: true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir minio
|
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
|
### Step 2:. Update compose file with your own values
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
environment:
|
environment:
|
||||||
- MINIO_ROOT_USER=<set minio root username>
|
- MINIO_ROOT_USER=<set minio root username>
|
||||||
- MINIO_ROOT_PASSWORD=<set minio root password>
|
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||||
# Uncomment and set your values if using our nginx proxy example
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
@@ -16,12 +16,12 @@ services:
|
|||||||
entrypoint: ""
|
entrypoint: ""
|
||||||
command: minio server /data
|
command: minio server /data
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/minio:/data
|
- ./data/minio:/data
|
||||||
# Uncomment if using our nginx proxy example
|
# Uncomment if using our nginx proxy example
|
||||||
# networks:
|
# networks:
|
||||||
# - proxy-tier
|
# - proxy-tier
|
||||||
|
|
||||||
# Uncomment if using our nginx proxy example
|
# Uncomment if using our nginx proxy example
|
||||||
#networks:
|
#networks:
|
||||||
# proxy-tier:
|
# proxy-tier:
|
||||||
# external: true
|
# external: true
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Acme-companion is a lightweight companion container for nginx-proxy. It handles
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir nginx-proxy
|
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.
|
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||||
|
|||||||
@@ -3,28 +3,28 @@ services:
|
|||||||
image: nginxproxy/nginx-proxy
|
image: nginxproxy/nginx-proxy
|
||||||
container_name: nginx-proxy
|
container_name: nginx-proxy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- html:/usr/share/nginx/html
|
- html:/usr/share/nginx/html
|
||||||
- certs:/etc/nginx/certs:ro
|
- certs:/etc/nginx/certs:ro
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- proxy-tier
|
- proxy-tier
|
||||||
|
|
||||||
acme-companion:
|
acme-companion:
|
||||||
image: nginxproxy/acme-companion
|
image: nginxproxy/acme-companion
|
||||||
container_name: nginx-proxy-acme
|
container_name: nginx-proxy-acme
|
||||||
environment:
|
environment:
|
||||||
- DEFAULT_EMAIL=mail@yourdomain.tld
|
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||||
volumes_from:
|
volumes_from:
|
||||||
- nginx-proxy
|
- nginx-proxy
|
||||||
volumes:
|
volumes:
|
||||||
- certs:/etc/nginx/certs:rw
|
- certs:/etc/nginx/certs:rw
|
||||||
- acme:/etc/acme.sh
|
- acme:/etc/acme.sh
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- proxy-tier
|
- proxy-tier
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy-tier:
|
proxy-tier:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ backend:
|
|||||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
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_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_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_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||||
OIDC_RP_CLIENT_ID: impress
|
OIDC_RP_CLIENT_ID: impress
|
||||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
@@ -46,9 +46,6 @@ backend:
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: root
|
AWS_S3_ACCESS_KEY_ID: root
|
||||||
@@ -85,7 +82,7 @@ backend:
|
|||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- name: certs
|
||||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
mountPath: /cert/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
|
|||||||
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
|
# 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
|
## Requirements
|
||||||
|
|
||||||
@@ -31,11 +31,17 @@ For older versions of Docker Engine that do not include Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p docs/env.d
|
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 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/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/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/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
|
## Step 2: Configuration
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol
|
|||||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
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_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_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_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||||
OIDC_RP_CLIENT_ID: impress
|
OIDC_RP_CLIENT_ID: impress
|
||||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
@@ -168,9 +168,6 @@ DB_NAME: impress
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find s3 bucket connection values
|
### Find s3 bucket connection values
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
|||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
|
# **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** 📝
|
# **Footer Configuration** 📝
|
||||||
|
|
||||||
The footer is configurable from the theme customization file.
|
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"
|
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
|
## Frontend File Watching Issues on Windows
|
||||||
|
|
||||||
### Problem Description
|
### Problem Description
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
|
|||||||
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
||||||
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
||||||
COLLABORATION_SERVER_SECRET=my-secret
|
COLLABORATION_SERVER_SECRET=my-secret
|
||||||
|
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
|
||||||
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||||
|
|
||||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||||
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||||
|
|
||||||
|
# Theme customization
|
||||||
|
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||||
@@ -2,4 +2,8 @@
|
|||||||
BURST_THROTTLE_RATES="200/minute"
|
BURST_THROTTLE_RATES="200/minute"
|
||||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
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_CLIENT_SECRET=<client secret>
|
||||||
OIDC_RP_SIGN_ALGO=RS256
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
OIDC_RP_SCOPES="openid email"
|
OIDC_RP_SCOPES="openid email"
|
||||||
#USER_OIDC_FIELD_TO_SHORTNAME
|
#OIDC_USERINFO_SHORTNAME_FIELD
|
||||||
#USER_OIDC_FIELDS_TO_FULLNAME
|
#OIDC_USERINFO_FULLNAME_FIELDS
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
LOGIN_REDIRECT_URL_FAILURE=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>
|
Y_PROVIDER_API_KEY=<generate a random key>
|
||||||
COLLABORATION_SERVER_SECRET=<generate a random key>
|
COLLABORATION_SERVER_SECRET=<generate a random key>
|
||||||
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog", "automated"],
|
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||||
|
"schedule": ["before 7am on monday"],
|
||||||
|
"prCreation": "not-pending",
|
||||||
|
"rebaseWhen": "conflicted",
|
||||||
|
"updateNotScheduled": false,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -15,15 +19,18 @@
|
|||||||
"matchPackageNames": ["redis"],
|
"matchPackageNames": ["redis"],
|
||||||
"allowedVersions": "<6.0.0"
|
"allowedVersions": "<6.0.0"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed pylint versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["pylint"],
|
||||||
|
"allowedVersions": "<4.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"@hocuspocus/provider",
|
|
||||||
"@hocuspocus/server",
|
|
||||||
"docx",
|
"docx",
|
||||||
"eslint",
|
|
||||||
"fetch-mock",
|
"fetch-mock",
|
||||||
"node",
|
"node",
|
||||||
"node-fetch",
|
"node-fetch",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from treebeard.admin import TreeAdmin
|
from treebeard.admin import TreeAdmin
|
||||||
from treebeard.forms import movenodeform_factory
|
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
form = movenodeform_factory(models.Document)
|
|
||||||
inlines = (DocumentAccessInline,)
|
inlines = (DocumentAccessInline,)
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
is_creator_me = django_filters.BooleanFilter(
|
is_creator_me = django_filters.BooleanFilter(
|
||||||
method="filter_is_creator_me", label=_("Creator is me")
|
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(
|
is_favorite = django_filters.BooleanFilter(
|
||||||
method="filter_is_favorite", label=_("Favorite")
|
method="filter_is_favorite", label=_("Favorite")
|
||||||
)
|
)
|
||||||
@@ -106,3 +109,30 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
return queryset.filter(is_favorite=bool(value))
|
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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.http import Http404
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core import choices
|
||||||
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class AccessPermission(permissions.BasePermission):
|
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||||
"""Permission class for access objects."""
|
"""A permission class for templates and invitations."""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for templates."""
|
||||||
return request.user.is_authenticated or view.action != "create"
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""Check permission for a given object."""
|
"""Check permission for a given object."""
|
||||||
abilities = obj.get_abilities(request.user)
|
abilities = obj.get_abilities(request.user)
|
||||||
action = view.action
|
action = view.action
|
||||||
try:
|
|
||||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return abilities.get(action, False)
|
return abilities.get(action, False)
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessPermission(AccessPermission):
|
class DocumentPermission(permissions.BasePermission):
|
||||||
"""Subclass to handle soft deletion specificities."""
|
"""Subclass to handle soft deletion specificities."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for documents."""
|
||||||
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""
|
"""
|
||||||
Return a 404 on deleted documents
|
Return a 404 on deleted documents
|
||||||
@@ -127,10 +129,61 @@ class DocumentAccessPermission(AccessPermission):
|
|||||||
) and deleted_at < get_trashbin_cutoff():
|
) and deleted_at < get_trashbin_cutoff():
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Compute permission first to ensure the "user_roles" attribute is set
|
abilities = obj.get_abilities(request.user)
|
||||||
has_permission = super().has_object_permission(request, view, obj)
|
action = view.action
|
||||||
|
try:
|
||||||
|
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_permission = abilities.get(action, False)
|
||||||
|
|
||||||
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return has_permission
|
return has_permission
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessPermission(IsAuthenticated):
|
||||||
|
"""Permission class for document access objects."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for accesses in documents tree."""
|
||||||
|
if super().has_permission(request, view) is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
role = getattr(view, view.resource_field_name).get_role(request.user)
|
||||||
|
if role not in choices.PRIVILEGED_ROLES:
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
"You are not allowed to manage accesses for this resource."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
abilities = obj.get_abilities(request.user)
|
||||||
|
|
||||||
|
requested_role = request.data.get("role")
|
||||||
|
if requested_role and requested_role not in abilities.get("set_role_to", []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
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."""
|
"""Client serializers for the impress core app."""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -7,12 +8,13 @@ from base64 import b64decode
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core import enums, models, utils
|
from core import choices, enums, models, utils, validators
|
||||||
from core.services.ai_services import AI_ACTIONS
|
from core.services.ai_services import AI_ACTIONS
|
||||||
from core.services.converter_services import (
|
from core.services.converter_services import (
|
||||||
ConversionError,
|
ConversionError,
|
||||||
@@ -23,143 +25,63 @@ from core.services.converter_services import (
|
|||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
|
|
||||||
|
full_name = serializers.SerializerMethodField(read_only=True)
|
||||||
|
short_name = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
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):
|
class UserLightSerializer(UserSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
|
|
||||||
id = serializers.SerializerMethodField(read_only=True)
|
|
||||||
email = serializers.SerializerMethodField(read_only=True)
|
|
||||||
|
|
||||||
def get_id(self, _user):
|
|
||||||
"""Return always None. Here to have the same fields than in UserSerializer."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_email(self, _user):
|
|
||||||
"""Return always None. Here to have the same fields than in UserSerializer."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name"]
|
fields = ["full_name", "short_name"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize template accesses."""
|
"""Serialize template accesses."""
|
||||||
|
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
"""Make "user" field is readonly but only on update."""
|
|
||||||
validated_data.pop("user", None)
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
def get_abilities(self, access) -> dict:
|
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request:
|
|
||||||
return access.get_abilities(request.user)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
"""
|
|
||||||
Check access rights specific to writing (create/update)
|
|
||||||
"""
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = getattr(request, "user", None)
|
|
||||||
role = attrs.get("role")
|
|
||||||
|
|
||||||
# Update
|
|
||||||
if self.instance:
|
|
||||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
|
||||||
|
|
||||||
if role and role not in can_set_role_to:
|
|
||||||
message = (
|
|
||||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
|
||||||
if can_set_role_to
|
|
||||||
else "You are not allowed to set this role for this template."
|
|
||||||
)
|
|
||||||
raise exceptions.PermissionDenied(message)
|
|
||||||
|
|
||||||
# Create
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resource_id = self.context["resource_id"]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise exceptions.ValidationError(
|
|
||||||
"You must set a resource ID in kwargs to create a new access."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists():
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"You are not allowed to manage accesses for this resource."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
role == models.RoleChoices.OWNER
|
|
||||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists()
|
|
||||||
):
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"Only owners of a resource can assign other users as owners."
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize document accesses."""
|
|
||||||
|
|
||||||
user_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
queryset=models.User.objects.all(),
|
|
||||||
write_only=True,
|
|
||||||
source="user",
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
)
|
|
||||||
user = UserSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DocumentAccess
|
|
||||||
resource_field_name = "document"
|
|
||||||
fields = ["id", "user", "user_id", "team", "role", "abilities"]
|
|
||||||
read_only_fields = ["id", "abilities"]
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
|
||||||
"""Serialize document accesses with limited fields."""
|
|
||||||
|
|
||||||
user = UserLightSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DocumentAccess
|
|
||||||
fields = ["id", "user", "team", "role", "abilities"]
|
|
||||||
read_only_fields = ["id", "team", "role", "abilities"]
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize template accesses."""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.TemplateAccess
|
model = models.TemplateAccess
|
||||||
resource_field_name = "template"
|
resource_field_name = "template"
|
||||||
fields = ["id", "user", "team", "role", "abilities"]
|
fields = ["id", "user", "team", "role", "abilities"]
|
||||||
read_only_fields = ["id", "abilities"]
|
read_only_fields = ["id", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field is readonly but only on update."""
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize documents with limited fields for display in lists."""
|
"""Serialize documents with limited fields for display in lists."""
|
||||||
@@ -167,16 +89,22 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
user_role = serializers.SerializerMethodField(read_only=True)
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -188,13 +116,18 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -205,49 +138,70 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_abilities(self, document) -> dict:
|
def to_representation(self, instance):
|
||||||
|
"""Precompute once per instance"""
|
||||||
|
paths_links_mapping = self.context.get("paths_links_mapping")
|
||||||
|
|
||||||
|
if paths_links_mapping is not None:
|
||||||
|
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
|
||||||
|
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
|
||||||
|
links
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
|
if not request:
|
||||||
|
return {}
|
||||||
|
|
||||||
if request:
|
return instance.get_abilities(request.user)
|
||||||
paths_links_mapping = self.context.get("paths_links_mapping", None)
|
|
||||||
# Retrieve ancestor links from paths_links_mapping (if provided)
|
|
||||||
ancestors_links = (
|
|
||||||
paths_links_mapping.get(document.path[: -document.steplen])
|
|
||||||
if paths_links_mapping
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return document.get_abilities(request.user, ancestors_links=ancestors_links)
|
|
||||||
|
|
||||||
return {}
|
def get_user_role(self, instance):
|
||||||
|
|
||||||
def get_user_roles(self, document):
|
|
||||||
"""
|
"""
|
||||||
Return roles of the logged-in user for the current document,
|
Return roles of the logged-in user for the current document,
|
||||||
taking into account ancestors.
|
taking into account ancestors.
|
||||||
"""
|
"""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
if request:
|
return instance.get_role(request.user) if request else None
|
||||||
return document.get_roles(request.user)
|
|
||||||
return []
|
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."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Document
|
||||||
|
fields = ["id", "path", "depth"]
|
||||||
|
read_only_fields = ["id", "path", "depth"]
|
||||||
|
|
||||||
|
|
||||||
class DocumentSerializer(ListDocumentSerializer):
|
class DocumentSerializer(ListDocumentSerializer):
|
||||||
"""Serialize documents with all fields for display in detail views."""
|
"""Serialize documents with all fields for display in detail views."""
|
||||||
|
|
||||||
content = serializers.CharField(required=False)
|
content = serializers.CharField(required=False)
|
||||||
|
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"content",
|
"content",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -259,13 +213,19 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
|
"websocket",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"link_role",
|
"link_role",
|
||||||
@@ -275,7 +235,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
@@ -361,6 +321,99 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
return super().save(**kwargs)
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize document accesses."""
|
||||||
|
|
||||||
|
document = DocumentLightSerializer(read_only=True)
|
||||||
|
user_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=models.User.objects.all(),
|
||||||
|
write_only=True,
|
||||||
|
source="user",
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
team = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"user_id",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_max_ancestors_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return getattr(instance, "max_ancestors_role", None)
|
||||||
|
|
||||||
|
def get_max_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return choices.RoleChoices.max(
|
||||||
|
getattr(instance, "max_ancestors_role", None),
|
||||||
|
instance.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field readonly but only on update."""
|
||||||
|
validated_data.pop("team", None)
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
||||||
|
"""Serialize document accesses with limited fields."""
|
||||||
|
|
||||||
|
user = UserLightSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ServerCreateDocumentSerializer(serializers.Serializer):
|
class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for creating a document from a server-to-server request.
|
Serializer for creating a document from a server-to-server request.
|
||||||
@@ -379,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
|||||||
content = serializers.CharField(required=True)
|
content = serializers.CharField(required=True)
|
||||||
# User
|
# User
|
||||||
sub = serializers.CharField(
|
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)
|
email = serializers.EmailField(required=True)
|
||||||
language = serializers.ChoiceField(
|
language = serializers.ChoiceField(
|
||||||
@@ -408,9 +461,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
|||||||
language = user.language or language
|
language = user.language or language
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document_content = YdocConverter().convert_markdown(
|
document_content = YdocConverter().convert(validated_data["content"])
|
||||||
validated_data["content"]
|
|
||||||
)
|
|
||||||
except ConversionError as err:
|
except ConversionError as err:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"content": ["Could not convert content"]}
|
{"content": ["Could not convert content"]}
|
||||||
@@ -465,6 +516,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
|||||||
We expose it separately from document in order to simplify and secure access control.
|
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:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
@@ -472,6 +527,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"link_reach",
|
"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):
|
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
@@ -643,6 +750,9 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
if self.instance is None:
|
if self.instance is None:
|
||||||
attrs["issuer"] = user
|
attrs["issuer"] = user
|
||||||
|
|
||||||
|
if attrs.get("email"):
|
||||||
|
attrs["email"] = attrs["email"].lower()
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def validate_role(self, role):
|
def validate_role(self, role):
|
||||||
@@ -677,7 +787,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
|||||||
"""Serializer for creating a document ask for access."""
|
"""Serializer for creating a document ask for access."""
|
||||||
|
|
||||||
role = serializers.ChoiceField(
|
role = serializers.ChoiceField(
|
||||||
choices=models.RoleChoices.choices,
|
choices=[
|
||||||
|
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
|
||||||
|
],
|
||||||
required=False,
|
required=False,
|
||||||
default=models.RoleChoices.READER,
|
default=models.RoleChoices.READER,
|
||||||
)
|
)
|
||||||
@@ -701,11 +813,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
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."""
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
if request:
|
if request:
|
||||||
return invitation.get_abilities(request.user)
|
return instance.get_abilities(request.user)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -780,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
|
|||||||
choices=enums.MoveNodePositionChoices.choices,
|
choices=enums.MoveNodePositionChoices.choices,
|
||||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
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 {}
|
||||||
|
|||||||
21
src/backend/core/api/throttling.py
Normal file
21
src/backend/core/api/throttling.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
|
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"
|
||||||
File diff suppressed because it is too large
Load Diff
117
src/backend/core/choices.py
Normal file
117
src/backend/core/choices.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Declare and configure choices for Docs' core application."""
|
||||||
|
|
||||||
|
from django.db.models import TextChoices
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityTextChoices(TextChoices):
|
||||||
|
"""
|
||||||
|
This class inherits from Django's TextChoices and provides a method to get the priority
|
||||||
|
of a given value based on its position in the class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_priority(cls, role):
|
||||||
|
"""Returns the priority of the given role based on its order in the class."""
|
||||||
|
|
||||||
|
members = list(cls.__members__.values())
|
||||||
|
return members.index(role) + 1 if role in members else 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def max(cls, *roles):
|
||||||
|
"""
|
||||||
|
Return the highest-priority role among the given roles, using get_priority().
|
||||||
|
If no valid roles are provided, returns None.
|
||||||
|
"""
|
||||||
|
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
|
||||||
|
if not valid_roles:
|
||||||
|
return None
|
||||||
|
return max(valid_roles, key=cls.get_priority)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkReachChoices(PriorityTextChoices):
|
||||||
|
"""Defines types of access for links"""
|
||||||
|
|
||||||
|
RESTRICTED = (
|
||||||
|
"restricted",
|
||||||
|
_("Restricted"),
|
||||||
|
) # Only users with a specific access can read/edit the document
|
||||||
|
AUTHENTICATED = (
|
||||||
|
"authenticated",
|
||||||
|
_("Authenticated"),
|
||||||
|
) # Any authenticated user can access the document
|
||||||
|
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_select_options(cls, link_reach, link_role):
|
||||||
|
"""
|
||||||
|
Determines the valid select options for link reach and link role depending on the
|
||||||
|
ancestors' link reach/role given as arguments.
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
reach: [
|
||||||
|
role
|
||||||
|
for role in LinkRoleChoices.values
|
||||||
|
if LinkRoleChoices.get_priority(role)
|
||||||
|
>= LinkRoleChoices.get_priority(link_role)
|
||||||
|
]
|
||||||
|
if reach != cls.RESTRICTED
|
||||||
|
else None
|
||||||
|
for reach in cls.values
|
||||||
|
if LinkReachChoices.get_priority(reach)
|
||||||
|
>= LinkReachChoices.get_priority(link_reach)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_equivalent_link_definition(ancestors_links):
|
||||||
|
"""
|
||||||
|
Return the (reach, role) pair with:
|
||||||
|
1. Highest reach
|
||||||
|
2. Highest role among links having that reach
|
||||||
|
"""
|
||||||
|
if not ancestors_links:
|
||||||
|
return {"link_reach": None, "link_role": None}
|
||||||
|
|
||||||
|
# 1) Find the highest reach
|
||||||
|
max_reach = max(
|
||||||
|
ancestors_links,
|
||||||
|
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
|
||||||
|
)["link_reach"]
|
||||||
|
|
||||||
|
# 2) Among those, find the highest role (ignore role if RESTRICTED)
|
||||||
|
if max_reach == LinkReachChoices.RESTRICTED:
|
||||||
|
max_role = None
|
||||||
|
else:
|
||||||
|
max_role = max(
|
||||||
|
(
|
||||||
|
link["link_role"]
|
||||||
|
for link in ancestors_links
|
||||||
|
if link["link_reach"] == max_reach
|
||||||
|
),
|
||||||
|
key=LinkRoleChoices.get_priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"link_reach": max_reach, "link_role": max_role}
|
||||||
@@ -150,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
"""Add link traces to document from a given list of users."""
|
"""Add link traces to document from a given list of users."""
|
||||||
if create and extracted:
|
if create and extracted:
|
||||||
for item in 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
|
@factory.post_generation
|
||||||
def favorited_by(self, create, extracted, **kwargs):
|
def favorited_by(self, create, extracted, **kwargs):
|
||||||
@@ -159,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
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):
|
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake document user accesses for testing."""
|
"""Create fake document user accesses for testing."""
|
||||||
@@ -247,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
|||||||
document = factory.SubFactory(DocumentFactory)
|
document = factory.SubFactory(DocumentFactory)
|
||||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||||
issuer = factory.SubFactory(UserFactory)
|
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)
|
||||||
|
|||||||
21
src/backend/core/middleware.py
Normal file
21
src/backend/core/middleware.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Force session creation for all requests."""
|
||||||
|
|
||||||
|
|
||||||
|
class ForceSessionMiddleware:
|
||||||
|
"""
|
||||||
|
Force session creation for unauthenticated users.
|
||||||
|
Must be used after Authentication middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
"""Initialize the middleware."""
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
"""Force session creation for unauthenticated users."""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated and request.session.session_key is None:
|
||||||
|
request.session.create()
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-14 14:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0022_alter_user_language_documentaskforaccess"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="has_deleted_children",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,7 +6,6 @@ Declare and configure the models for the impress core application
|
|||||||
import hashlib
|
import hashlib
|
||||||
import smtplib
|
import smtplib
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from django.contrib.auth import models as auth_models
|
|||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.contrib.sites.models import Site
|
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.cache import cache
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
@@ -33,6 +32,15 @@ from rest_framework.exceptions import ValidationError
|
|||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||||
|
|
||||||
|
from .choices import (
|
||||||
|
PRIVILEGED_ROLES,
|
||||||
|
LinkReachChoices,
|
||||||
|
LinkRoleChoices,
|
||||||
|
RoleChoices,
|
||||||
|
get_equivalent_link_definition,
|
||||||
|
)
|
||||||
|
from .validators import sub_validator
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,88 +58,6 @@ def get_trashbin_cutoff():
|
|||||||
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
|
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
|
||||||
|
|
||||||
|
|
||||||
class LinkRoleChoices(models.TextChoices):
|
|
||||||
"""Defines the possible roles a link can offer on a document."""
|
|
||||||
|
|
||||||
READER = "reader", _("Reader") # Can read
|
|
||||||
EDITOR = "editor", _("Editor") # Can read and edit
|
|
||||||
|
|
||||||
|
|
||||||
class RoleChoices(models.TextChoices):
|
|
||||||
"""Defines the possible roles a user can have in a resource."""
|
|
||||||
|
|
||||||
READER = "reader", _("Reader") # Can read
|
|
||||||
EDITOR = "editor", _("Editor") # Can read and edit
|
|
||||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
|
||||||
OWNER = "owner", _("Owner")
|
|
||||||
|
|
||||||
|
|
||||||
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
|
||||||
|
|
||||||
|
|
||||||
class LinkReachChoices(models.TextChoices):
|
|
||||||
"""Defines types of access for links"""
|
|
||||||
|
|
||||||
RESTRICTED = (
|
|
||||||
"restricted",
|
|
||||||
_("Restricted"),
|
|
||||||
) # Only users with a specific access can read/edit the document
|
|
||||||
AUTHENTICATED = (
|
|
||||||
"authenticated",
|
|
||||||
_("Authenticated"),
|
|
||||||
) # Any authenticated user can access the document
|
|
||||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_select_options(cls, ancestors_links):
|
|
||||||
"""
|
|
||||||
Determines the valid select options for link reach and link role depending on the
|
|
||||||
list of ancestors' link reach/role.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
|
|
||||||
representing the reach and role of ancestors links.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping possible reach levels to their corresponding possible roles.
|
|
||||||
"""
|
|
||||||
# If no ancestors, return all options
|
|
||||||
if not ancestors_links:
|
|
||||||
return dict.fromkeys(cls.values, LinkRoleChoices.values)
|
|
||||||
|
|
||||||
# Initialize result with all possible reaches and role options as sets
|
|
||||||
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
|
|
||||||
|
|
||||||
# Group roles by reach level
|
|
||||||
reach_roles = defaultdict(set)
|
|
||||||
for link in ancestors_links:
|
|
||||||
reach_roles[link["link_reach"]].add(link["link_role"])
|
|
||||||
|
|
||||||
# Apply constraints based on ancestor links
|
|
||||||
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
|
|
||||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
|
||||||
|
|
||||||
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
|
|
||||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
|
||||||
result.pop(cls.RESTRICTED, None)
|
|
||||||
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
|
|
||||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
|
||||||
|
|
||||||
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
|
|
||||||
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
|
|
||||||
result.pop(cls.AUTHENTICATED, None)
|
|
||||||
result.pop(cls.RESTRICTED, None)
|
|
||||||
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
|
|
||||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
|
||||||
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
|
|
||||||
|
|
||||||
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
|
|
||||||
for reach, roles in result.items():
|
|
||||||
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class DuplicateEmailError(Exception):
|
class DuplicateEmailError(Exception):
|
||||||
"""Raised when an email is already associated with a pre-existing user."""
|
"""Raised when an email is already associated with a pre-existing user."""
|
||||||
|
|
||||||
@@ -211,28 +137,20 @@ class UserManager(auth_models.UserManager):
|
|||||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||||
"""User model to work with OIDC only authentication."""
|
"""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 = models.CharField(
|
||||||
_("sub"),
|
_("sub"),
|
||||||
help_text=_(
|
help_text=_("Required. 255 characters or fewer. ASCII characters only."),
|
||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
|
||||||
),
|
|
||||||
max_length=255,
|
max_length=255,
|
||||||
unique=True,
|
|
||||||
validators=[sub_validator],
|
validators=[sub_validator],
|
||||||
|
unique=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=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)
|
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||||
|
|
||||||
@@ -305,7 +223,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
Expired invitations are ignored.
|
Expired invitations are ignored.
|
||||||
"""
|
"""
|
||||||
valid_invitations = Invitation.objects.filter(
|
valid_invitations = Invitation.objects.filter(
|
||||||
email=self.email,
|
email__iexact=self.email,
|
||||||
created_at__gte=(
|
created_at__gte=(
|
||||||
timezone.now()
|
timezone.now()
|
||||||
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||||
@@ -364,69 +282,6 @@ class BaseAccess(BaseModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def _get_roles(self, resource, user):
|
|
||||||
"""
|
|
||||||
Get the roles a user has on a resource.
|
|
||||||
"""
|
|
||||||
roles = []
|
|
||||||
if user.is_authenticated:
|
|
||||||
teams = user.teams
|
|
||||||
try:
|
|
||||||
roles = self.user_roles or []
|
|
||||||
except AttributeError:
|
|
||||||
try:
|
|
||||||
roles = resource.accesses.filter(
|
|
||||||
models.Q(user=user) | models.Q(team__in=teams),
|
|
||||||
).values_list("role", flat=True)
|
|
||||||
except (self._meta.model.DoesNotExist, IndexError):
|
|
||||||
roles = []
|
|
||||||
|
|
||||||
return roles
|
|
||||||
|
|
||||||
def _get_abilities(self, resource, user):
|
|
||||||
"""
|
|
||||||
Compute and return abilities for a given user taking into account
|
|
||||||
the current state of the object.
|
|
||||||
"""
|
|
||||||
roles = self._get_roles(resource, user)
|
|
||||||
|
|
||||||
is_owner_or_admin = bool(
|
|
||||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
|
||||||
)
|
|
||||||
if self.role == RoleChoices.OWNER:
|
|
||||||
can_delete = (
|
|
||||||
RoleChoices.OWNER in roles
|
|
||||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
|
||||||
)
|
|
||||||
set_role_to = (
|
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
|
||||||
if can_delete
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
can_delete = is_owner_or_admin
|
|
||||||
set_role_to = []
|
|
||||||
if RoleChoices.OWNER in roles:
|
|
||||||
set_role_to.append(RoleChoices.OWNER)
|
|
||||||
if is_owner_or_admin:
|
|
||||||
set_role_to.extend(
|
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove the current role as we don't want to propose it as an option
|
|
||||||
try:
|
|
||||||
set_role_to.remove(self.role)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"destroy": can_delete,
|
|
||||||
"update": bool(set_role_to),
|
|
||||||
"partial_update": bool(set_role_to),
|
|
||||||
"retrieve": bool(roles),
|
|
||||||
"set_role_to": set_role_to,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentQuerySet(MP_NodeQuerySet):
|
class DocumentQuerySet(MP_NodeQuerySet):
|
||||||
"""
|
"""
|
||||||
@@ -452,6 +307,41 @@ class DocumentQuerySet(MP_NodeQuerySet):
|
|||||||
|
|
||||||
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||||
|
|
||||||
|
def annotate_is_favorite(self, user):
|
||||||
|
"""
|
||||||
|
Annotate document queryset with the favorite status for the current user.
|
||||||
|
"""
|
||||||
|
if user.is_authenticated:
|
||||||
|
favorite_exists_subquery = DocumentFavorite.objects.filter(
|
||||||
|
document_id=models.OuterRef("pk"), user=user
|
||||||
|
)
|
||||||
|
return self.annotate(is_favorite=models.Exists(favorite_exists_subquery))
|
||||||
|
|
||||||
|
return self.annotate(is_favorite=models.Value(False))
|
||||||
|
|
||||||
|
def annotate_user_roles(self, user):
|
||||||
|
"""
|
||||||
|
Annotate document queryset with the roles of the current user
|
||||||
|
on the document or its ancestors.
|
||||||
|
"""
|
||||||
|
output_field = ArrayField(base_field=models.CharField())
|
||||||
|
|
||||||
|
if user.is_authenticated:
|
||||||
|
user_roles_subquery = DocumentAccess.objects.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
|
document__path=Left(models.OuterRef("path"), Length("document__path")),
|
||||||
|
).values_list("role", flat=True)
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
user_roles=models.Func(
|
||||||
|
user_roles_subquery, function="ARRAY", output_field=output_field
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
user_roles=models.Value([], output_field=output_field),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
||||||
"""
|
"""
|
||||||
@@ -464,6 +354,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
|||||||
return self._queryset_class(self.model).order_by("path")
|
return self._queryset_class(self.model).order_by("path")
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
class Document(MP_Node, BaseModel):
|
class Document(MP_Node, BaseModel):
|
||||||
"""Pad document carrying the content."""
|
"""Pad document carrying the content."""
|
||||||
|
|
||||||
@@ -486,6 +377,7 @@ class Document(MP_Node, BaseModel):
|
|||||||
)
|
)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
has_deleted_children = models.BooleanField(default=False)
|
||||||
duplicated_from = models.ForeignKey(
|
duplicated_from = models.ForeignKey(
|
||||||
"self",
|
"self",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -531,6 +423,12 @@ class Document(MP_Node, BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.title) if self.title else str(_("Untitled Document"))
|
return str(self.title) if self.title else str(_("Untitled Document"))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize cache property."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._ancestors_link_definition = None
|
||||||
|
self._computed_link_definition = None
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Write content to object storage only if _content has changed."""
|
"""Write content to object storage only if _content has changed."""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
@@ -561,6 +459,12 @@ class Document(MP_Node, BaseModel):
|
|||||||
content_file = ContentFile(bytes_content)
|
content_file = ContentFile(bytes_content)
|
||||||
default_storage.save(file_key, content_file)
|
default_storage.save(file_key, content_file)
|
||||||
|
|
||||||
|
def is_leaf(self):
|
||||||
|
"""
|
||||||
|
:returns: True if the node is has no children
|
||||||
|
"""
|
||||||
|
return not self.has_deleted_children and self.numchild == 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key_base(self):
|
def key_base(self):
|
||||||
"""Key base of the location where the document is stored in object storage."""
|
"""Key base of the location where the document is stored in object storage."""
|
||||||
@@ -718,38 +622,22 @@ class Document(MP_Node, BaseModel):
|
|||||||
cache_key = document.get_nb_accesses_cache_key()
|
cache_key = document.get_nb_accesses_cache_key()
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
|
||||||
def get_roles(self, user):
|
def get_role(self, user):
|
||||||
"""Return the roles a user has on a document."""
|
"""Return the roles a user has on a document."""
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return []
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
roles = self.user_roles or []
|
roles = self.user_roles or []
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
try:
|
roles = DocumentAccess.objects.filter(
|
||||||
roles = DocumentAccess.objects.filter(
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
models.Q(user=user) | models.Q(team__in=user.teams),
|
document__path=Left(models.Value(self.path), Length("document__path")),
|
||||||
document__path=Left(
|
).values_list("role", flat=True)
|
||||||
models.Value(self.path), Length("document__path")
|
|
||||||
),
|
|
||||||
).values_list("role", flat=True)
|
|
||||||
except (models.ObjectDoesNotExist, IndexError):
|
|
||||||
roles = []
|
|
||||||
return roles
|
|
||||||
|
|
||||||
def get_links_definitions(self, ancestors_links):
|
return RoleChoices.max(*roles)
|
||||||
"""Get links reach/role definitions for the current document and its ancestors."""
|
|
||||||
|
|
||||||
links_definitions = defaultdict(set)
|
def compute_ancestors_links_paths_mapping(self):
|
||||||
links_definitions[self.link_reach].add(self.link_role)
|
|
||||||
|
|
||||||
# Merge ancestor link definitions
|
|
||||||
for ancestor in ancestors_links:
|
|
||||||
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
|
|
||||||
|
|
||||||
return dict(links_definitions) # Convert default dict back to a normal dict
|
|
||||||
|
|
||||||
def compute_ancestors_links(self, user):
|
|
||||||
"""
|
"""
|
||||||
Compute the ancestors links for the current document up to the highest readable ancestor.
|
Compute the ancestors links for the current document up to the highest readable ancestor.
|
||||||
"""
|
"""
|
||||||
@@ -758,63 +646,122 @@ class Document(MP_Node, BaseModel):
|
|||||||
.filter(ancestors_deleted_at__isnull=True)
|
.filter(ancestors_deleted_at__isnull=True)
|
||||||
.order_by("path")
|
.order_by("path")
|
||||||
)
|
)
|
||||||
highest_readable = ancestors.readable_per_se(user).only("depth").first()
|
|
||||||
|
|
||||||
if highest_readable is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ancestors_links = []
|
ancestors_links = []
|
||||||
paths_links_mapping = {}
|
paths_links_mapping = {}
|
||||||
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
|
|
||||||
|
for ancestor in ancestors:
|
||||||
ancestors_links.append(
|
ancestors_links.append(
|
||||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||||
)
|
)
|
||||||
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
||||||
|
|
||||||
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
|
return paths_links_mapping
|
||||||
|
|
||||||
return ancestors_links
|
@property
|
||||||
|
def link_definition(self):
|
||||||
|
"""Returns link reach/role as a definition in dictionary format."""
|
||||||
|
return {"link_reach": self.link_reach, "link_role": self.link_role}
|
||||||
|
|
||||||
def get_abilities(self, user, ancestors_links=None):
|
@property
|
||||||
|
def ancestors_link_definition(self):
|
||||||
|
"""Link definition equivalent to all document's ancestors."""
|
||||||
|
if getattr(self, "_ancestors_link_definition", None) is None:
|
||||||
|
if self.depth <= 1:
|
||||||
|
ancestors_links = []
|
||||||
|
else:
|
||||||
|
mapping = self.compute_ancestors_links_paths_mapping()
|
||||||
|
ancestors_links = mapping.get(self.path[: -self.steplen], [])
|
||||||
|
self._ancestors_link_definition = get_equivalent_link_definition(
|
||||||
|
ancestors_links
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._ancestors_link_definition
|
||||||
|
|
||||||
|
@ancestors_link_definition.setter
|
||||||
|
def ancestors_link_definition(self, definition):
|
||||||
|
"""Cache the ancestors_link_definition."""
|
||||||
|
self._ancestors_link_definition = definition
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors_link_reach(self):
|
||||||
|
"""Link reach equivalent to all document's ancestors."""
|
||||||
|
return self.ancestors_link_definition["link_reach"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors_link_role(self):
|
||||||
|
"""Link role equivalent to all document's ancestors."""
|
||||||
|
return self.ancestors_link_definition["link_role"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_definition(self):
|
||||||
|
"""
|
||||||
|
Link reach/role on the document, combining inherited ancestors' link
|
||||||
|
definitions and the document's own link definition.
|
||||||
|
"""
|
||||||
|
if getattr(self, "_computed_link_definition", None) is None:
|
||||||
|
self._computed_link_definition = get_equivalent_link_definition(
|
||||||
|
[self.ancestors_link_definition, self.link_definition]
|
||||||
|
)
|
||||||
|
return self._computed_link_definition
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_reach(self):
|
||||||
|
"""Actual link reach on the document."""
|
||||||
|
return self.computed_link_definition["link_reach"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_role(self):
|
||||||
|
"""Actual link role on the document."""
|
||||||
|
return self.computed_link_definition["link_role"]
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the document.
|
Compute and return abilities for a given user on the document.
|
||||||
"""
|
"""
|
||||||
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
|
# First get the role based on specific access
|
||||||
ancestors_links = []
|
role = self.get_role(user)
|
||||||
elif ancestors_links is None:
|
|
||||||
ancestors_links = self.compute_ancestors_links(user=user)
|
|
||||||
|
|
||||||
roles = set(
|
|
||||||
self.get_roles(user)
|
|
||||||
) # at this point only roles based on specific access
|
|
||||||
|
|
||||||
# Characteristics that are based only on specific access
|
# Characteristics that are based only on specific access
|
||||||
is_owner = RoleChoices.OWNER in roles
|
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 RoleChoices.ADMIN in roles) and not is_deleted
|
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
|
# Compute access roles before adding link roles because we don't
|
||||||
# want anonymous users to access versions (we wouldn't know from
|
# want anonymous users to access versions (we wouldn't know from
|
||||||
# which date to allow them anyway)
|
# which date to allow them anyway)
|
||||||
# Anonymous users should also not see document accesses
|
# Anonymous users should also not see document accesses
|
||||||
has_access_role = bool(roles) and not is_deleted
|
has_access_role = bool(role) and not is_deleted
|
||||||
can_update_from_access = (
|
can_update_from_access = (
|
||||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
is_owner_or_admin or role == RoleChoices.EDITOR
|
||||||
) and not is_deleted
|
) and not is_deleted
|
||||||
|
|
||||||
# Add roles provided by the document link, taking into account its ancestors
|
link_select_options = LinkReachChoices.get_select_options(
|
||||||
links_definitions = self.get_links_definitions(ancestors_links)
|
**self.ancestors_link_definition
|
||||||
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
|
)
|
||||||
authenticated_roles = (
|
link_definition = get_equivalent_link_definition(
|
||||||
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
|
[
|
||||||
if user.is_authenticated
|
self.ancestors_link_definition,
|
||||||
else set()
|
{"link_reach": self.link_reach, "link_role": self.link_role},
|
||||||
|
]
|
||||||
)
|
)
|
||||||
roles = roles | public_roles | authenticated_roles
|
|
||||||
|
|
||||||
can_get = bool(roles) and not is_deleted
|
link_reach = link_definition["link_reach"]
|
||||||
|
if link_reach == LinkReachChoices.PUBLIC or (
|
||||||
|
link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
|
||||||
|
):
|
||||||
|
role = RoleChoices.max(role, link_definition["link_role"])
|
||||||
|
|
||||||
|
can_get = bool(role) and not is_deleted
|
||||||
|
retrieve = can_get or is_owner
|
||||||
can_update = (
|
can_update = (
|
||||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
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
|
) and not is_deleted
|
||||||
|
|
||||||
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
||||||
@@ -836,23 +783,27 @@ class Document(MP_Node, BaseModel):
|
|||||||
"ai_translate": ai_access,
|
"ai_translate": ai_access,
|
||||||
"attachment_upload": can_update,
|
"attachment_upload": can_update,
|
||||||
"media_check": can_get,
|
"media_check": can_get,
|
||||||
|
"can_edit": can_update,
|
||||||
"children_list": can_get,
|
"children_list": can_get,
|
||||||
"children_create": can_update and user.is_authenticated,
|
"children_create": can_create_children,
|
||||||
"collaboration_auth": can_get,
|
"collaboration_auth": can_get,
|
||||||
|
"comment": can_comment,
|
||||||
|
"content": can_get,
|
||||||
"cors_proxy": can_get,
|
"cors_proxy": can_get,
|
||||||
"descendants": can_get,
|
"descendants": can_get,
|
||||||
"destroy": is_owner,
|
"destroy": can_destroy,
|
||||||
"duplicate": can_get,
|
"duplicate": can_get and user.is_authenticated,
|
||||||
"favorite": can_get and user.is_authenticated,
|
"favorite": can_get and user.is_authenticated,
|
||||||
"link_configuration": is_owner_or_admin,
|
"link_configuration": is_owner_or_admin,
|
||||||
"invite_owner": is_owner,
|
"invite_owner": is_owner and not is_deleted,
|
||||||
"move": is_owner_or_admin and not self.ancestors_deleted_at,
|
"mask": can_get and user.is_authenticated,
|
||||||
|
"move": is_owner_or_admin and not is_deleted,
|
||||||
"partial_update": can_update,
|
"partial_update": can_update,
|
||||||
"restore": is_owner,
|
"restore": is_owner,
|
||||||
"retrieve": can_get,
|
"retrieve": retrieve,
|
||||||
"media_auth": can_get,
|
"media_auth": can_get,
|
||||||
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
|
"link_select_options": link_select_options,
|
||||||
"tree": can_get,
|
"tree": retrieve,
|
||||||
"update": can_update,
|
"update": can_update,
|
||||||
"versions_destroy": is_owner_or_admin,
|
"versions_destroy": is_owner_or_admin,
|
||||||
"versions_list": has_access_role,
|
"versions_list": has_access_role,
|
||||||
@@ -946,7 +897,8 @@ class Document(MP_Node, BaseModel):
|
|||||||
|
|
||||||
if self.depth > 1:
|
if self.depth > 1:
|
||||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||||
numchild=models.F("numchild") - 1
|
numchild=models.F("numchild") - 1,
|
||||||
|
has_deleted_children=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark all descendants as soft deleted
|
# Mark all descendants as soft deleted
|
||||||
@@ -1010,6 +962,7 @@ class LinkTrace(BaseModel):
|
|||||||
related_name="link_traces",
|
related_name="link_traces",
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
||||||
|
is_masked = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "impress_link_trace"
|
db_table = "impress_link_trace"
|
||||||
@@ -1103,48 +1056,125 @@ class DocumentAccess(BaseAccess):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
self.document.invalidate_nb_accesses_cache()
|
self.document.invalidate_nb_accesses_cache()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_key(self):
|
||||||
|
"""Get a unique key for the actor targeted by the access, without possible conflict."""
|
||||||
|
return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}"
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""Override delete to clear the document's cache for number of accesses."""
|
"""Override delete to clear the document's cache for number of accesses."""
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
self.document.invalidate_nb_accesses_cache()
|
self.document.invalidate_nb_accesses_cache()
|
||||||
|
|
||||||
|
def set_user_roles_tuple(self, ancestors_role, current_role):
|
||||||
|
"""
|
||||||
|
Set a precomputed (ancestor_role, current_role) tuple for this instance.
|
||||||
|
|
||||||
|
This avoids querying the database in `get_roles_tuple()` and is useful
|
||||||
|
when roles are already known, such as in bulk serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ancestor_role (str | None): Highest role on any ancestor document.
|
||||||
|
current_role (str | None): Role on the current document.
|
||||||
|
"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self._prefetched_user_roles_tuple = (ancestors_role, current_role)
|
||||||
|
|
||||||
|
def get_user_roles_tuple(self, user):
|
||||||
|
"""
|
||||||
|
Return a tuple of:
|
||||||
|
- the highest role the user has on any ancestor of the document
|
||||||
|
- the role the user has on the current document
|
||||||
|
|
||||||
|
If roles have been explicitly set using `set_user_roles_tuple()`,
|
||||||
|
those will be returned instead of querying the database.
|
||||||
|
|
||||||
|
This allows viewsets or serializers to precompute roles for performance
|
||||||
|
when handling multiple documents at once.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): The user whose roles are being evaluated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str | None, str | None]: (max_ancestor_role, current_document_role)
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._prefetched_user_roles_tuple
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ancestors = (
|
||||||
|
self.document.get_ancestors() | Document.objects.filter(pk=self.document_id)
|
||||||
|
).filter(ancestors_deleted_at__isnull=True)
|
||||||
|
|
||||||
|
access_tuples = DocumentAccess.objects.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
|
document__in=ancestors,
|
||||||
|
).values_list("document_id", "role")
|
||||||
|
|
||||||
|
ancestors_roles = []
|
||||||
|
current_roles = []
|
||||||
|
for doc_id, role in access_tuples:
|
||||||
|
if doc_id == self.document_id:
|
||||||
|
current_roles.append(role)
|
||||||
|
else:
|
||||||
|
ancestors_roles.append(role)
|
||||||
|
|
||||||
|
return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the document access.
|
Compute and return abilities for a given user on the document access.
|
||||||
"""
|
"""
|
||||||
roles = self._get_roles(self.document, user)
|
ancestors_role, current_role = self.get_user_roles_tuple(user)
|
||||||
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
|
role = RoleChoices.max(ancestors_role, current_role)
|
||||||
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
|
|
||||||
if self.role == RoleChoices.OWNER:
|
if self.role == RoleChoices.OWNER:
|
||||||
can_delete = (
|
can_delete = role == RoleChoices.OWNER and (
|
||||||
RoleChoices.OWNER in roles
|
# check if document is not root trying to avoid an extra query
|
||||||
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
self.document.depth > 1
|
||||||
)
|
or DocumentAccess.objects.filter(
|
||||||
set_role_to = (
|
document_id=self.document_id, role=RoleChoices.OWNER
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
).count()
|
||||||
if can_delete
|
> 1
|
||||||
else []
|
|
||||||
)
|
)
|
||||||
|
set_role_to = RoleChoices.values if can_delete else []
|
||||||
else:
|
else:
|
||||||
can_delete = is_owner_or_admin
|
can_delete = is_owner_or_admin
|
||||||
set_role_to = []
|
set_role_to = []
|
||||||
if RoleChoices.OWNER in roles:
|
|
||||||
set_role_to.append(RoleChoices.OWNER)
|
|
||||||
if is_owner_or_admin:
|
if is_owner_or_admin:
|
||||||
set_role_to.extend(
|
set_role_to.extend(
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
[
|
||||||
|
RoleChoices.READER,
|
||||||
|
RoleChoices.COMMENTER,
|
||||||
|
RoleChoices.EDITOR,
|
||||||
|
RoleChoices.ADMIN,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
if role == RoleChoices.OWNER:
|
||||||
|
set_role_to.append(RoleChoices.OWNER)
|
||||||
|
|
||||||
# Remove the current role as we don't want to propose it as an option
|
# Filter out roles that would be lower than the one the user already has
|
||||||
try:
|
ancestors_role_priority = RoleChoices.get_priority(
|
||||||
set_role_to.remove(self.role)
|
getattr(self, "max_ancestors_role", None)
|
||||||
except ValueError:
|
)
|
||||||
pass
|
set_role_to = [
|
||||||
|
candidate_role
|
||||||
|
for candidate_role in set_role_to
|
||||||
|
if RoleChoices.get_priority(candidate_role) >= ancestors_role_priority
|
||||||
|
]
|
||||||
|
if len(set_role_to) == 1:
|
||||||
|
set_role_to = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destroy": can_delete,
|
"destroy": can_delete,
|
||||||
"update": bool(set_role_to) and is_owner_or_admin,
|
"update": bool(set_role_to) and is_owner_or_admin,
|
||||||
"partial_update": bool(set_role_to) and is_owner_or_admin,
|
"partial_update": bool(set_role_to) and is_owner_or_admin,
|
||||||
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
|
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
|
||||||
"set_role_to": set_role_to,
|
"set_role_to": set_role_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,23 +1212,14 @@ class DocumentAskForAccess(BaseModel):
|
|||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""Compute and return abilities for a given 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:
|
set_role_to = [
|
||||||
teams = user.teams
|
role
|
||||||
try:
|
for role in RoleChoices.values
|
||||||
roles = self.user_roles or []
|
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
|
||||||
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})
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destroy": is_admin_or_owner,
|
"destroy": is_admin_or_owner,
|
||||||
@@ -1206,6 +1227,7 @@ class DocumentAskForAccess(BaseModel):
|
|||||||
"partial_update": is_admin_or_owner,
|
"partial_update": is_admin_or_owner,
|
||||||
"retrieve": is_admin_or_owner,
|
"retrieve": is_admin_or_owner,
|
||||||
"accept": is_admin_or_owner,
|
"accept": is_admin_or_owner,
|
||||||
|
"set_role_to": set_role_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
def accept(self, role=None):
|
def accept(self, role=None):
|
||||||
@@ -1255,6 +1277,153 @@ class DocumentAskForAccess(BaseModel):
|
|||||||
self.document.send_email(subject, [email], context, language)
|
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):
|
class Template(BaseModel):
|
||||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||||
|
|
||||||
@@ -1277,10 +1446,10 @@ class Template(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def get_roles(self, user):
|
def get_role(self, user):
|
||||||
"""Return the roles a user has on a resource as an iterable."""
|
"""Return the roles a user has on a resource as an iterable."""
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return []
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
roles = self.user_roles or []
|
roles = self.user_roles or []
|
||||||
@@ -1291,21 +1460,20 @@ class Template(BaseModel):
|
|||||||
).values_list("role", flat=True)
|
).values_list("role", flat=True)
|
||||||
except (models.ObjectDoesNotExist, IndexError):
|
except (models.ObjectDoesNotExist, IndexError):
|
||||||
roles = []
|
roles = []
|
||||||
return roles
|
|
||||||
|
return RoleChoices.max(*roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the template.
|
Compute and return abilities for a given user on the template.
|
||||||
"""
|
"""
|
||||||
roles = self.get_roles(user)
|
role = self.get_role(user)
|
||||||
is_owner_or_admin = bool(
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
can_get = self.is_public or bool(role)
|
||||||
)
|
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
|
||||||
can_get = self.is_public or bool(roles)
|
|
||||||
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destroy": RoleChoices.OWNER in roles,
|
"destroy": role == RoleChoices.OWNER,
|
||||||
"generate_document": can_get,
|
"generate_document": can_get,
|
||||||
"accesses_manage": is_owner_or_admin,
|
"accesses_manage": is_owner_or_admin,
|
||||||
"update": can_update,
|
"update": can_update,
|
||||||
@@ -1352,11 +1520,65 @@ class TemplateAccess(BaseAccess):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
||||||
|
|
||||||
|
def get_role(self, user):
|
||||||
|
"""
|
||||||
|
Get the role a user has on a resource.
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
roles = self.user_roles or []
|
||||||
|
except AttributeError:
|
||||||
|
teams = user.teams
|
||||||
|
try:
|
||||||
|
roles = self.template.accesses.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=teams),
|
||||||
|
).values_list("role", flat=True)
|
||||||
|
except (Template.DoesNotExist, IndexError):
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
return RoleChoices.max(*roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the template access.
|
Compute and return abilities for a given user on the template access.
|
||||||
"""
|
"""
|
||||||
return self._get_abilities(self.template, user)
|
role = self.get_role(user)
|
||||||
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
|
|
||||||
|
if self.role == RoleChoices.OWNER:
|
||||||
|
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
|
||||||
|
role=RoleChoices.OWNER
|
||||||
|
).count() > 1
|
||||||
|
set_role_to = (
|
||||||
|
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||||
|
if can_delete
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
can_delete = is_owner_or_admin
|
||||||
|
set_role_to = []
|
||||||
|
if role == RoleChoices.OWNER:
|
||||||
|
set_role_to.append(RoleChoices.OWNER)
|
||||||
|
if is_owner_or_admin:
|
||||||
|
set_role_to.extend(
|
||||||
|
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the current role as we don't want to propose it as an option
|
||||||
|
try:
|
||||||
|
set_role_to.remove(self.role)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"destroy": can_delete,
|
||||||
|
"update": bool(set_role_to),
|
||||||
|
"partial_update": bool(set_role_to),
|
||||||
|
"retrieve": bool(role),
|
||||||
|
"set_role_to": set_role_to,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Invitation(BaseModel):
|
class Invitation(BaseModel):
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from core import enums
|
|||||||
|
|
||||||
AI_ACTIONS = {
|
AI_ACTIONS = {
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Answer the prompt in markdown format. "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
"Preserve the language and markdown formatting. "
|
"Preserve the language and markdown formatting. "
|
||||||
"Do not provide any other information. "
|
"Do not provide any other information. "
|
||||||
"Preserve the language."
|
"Preserve the language."
|
||||||
|
|||||||
@@ -41,3 +41,35 @@ class CollaborationService:
|
|||||||
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||||
f"Response: {response.text}"
|
f"Response: {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_document_connection_info(self, room, session_key):
|
||||||
|
"""
|
||||||
|
Get the connection info for a document.
|
||||||
|
"""
|
||||||
|
endpoint = "get-connections"
|
||||||
|
querystring = {
|
||||||
|
"room": room,
|
||||||
|
"sessionKey": session_key,
|
||||||
|
}
|
||||||
|
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
|
||||||
|
|
||||||
|
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
endpoint_url, headers=headers, params=querystring, timeout=10
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise requests.HTTPError("Failed to get document connection info.") from e
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
return result.get("count", 0), result.get("exists", False)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"Failed to get document connection info. Status code: {response.status_code}, "
|
||||||
|
f"Response: {response.text}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Converter services."""
|
"""Y-Provider API services."""
|
||||||
|
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
|
|||||||
"""Raised when the conversion service is unavailable."""
|
"""Raised when the conversion service is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidResponseError(ConversionError):
|
|
||||||
"""Raised when the conversion service returns an invalid response."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingContentError(ConversionError):
|
|
||||||
"""Raised when the response is missing required content."""
|
|
||||||
|
|
||||||
|
|
||||||
class YdocConverter:
|
class YdocConverter:
|
||||||
"""Service class for conversion-related operations."""
|
"""Service class for conversion-related operations."""
|
||||||
|
|
||||||
@@ -32,47 +26,47 @@ class YdocConverter:
|
|||||||
def auth_header(self):
|
def auth_header(self):
|
||||||
"""Build microservice authentication header."""
|
"""Build microservice authentication header."""
|
||||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||||
return settings.Y_PROVIDER_API_KEY
|
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
|
||||||
|
|
||||||
def convert_markdown(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."""
|
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
raise ValidationError("Input text cannot be empty")
|
raise ValidationError("Input text cannot be empty")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = self._request(
|
||||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||||
json={
|
text,
|
||||||
"content": text,
|
content_type,
|
||||||
},
|
accept,
|
||||||
headers={
|
|
||||||
"Authorization": self.auth_header,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
|
||||||
verify=settings.CONVERSION_API_SECURE,
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if accept == "application/vnd.yjs.doc":
|
||||||
conversion_response = response.json()
|
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:
|
except requests.RequestException as err:
|
||||||
raise ServiceUnavailableError(
|
raise ServiceUnavailableError(
|
||||||
"Failed to connect to conversion service",
|
"Failed to connect to conversion service",
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
except ValueError as err:
|
|
||||||
raise InvalidResponseError(
|
|
||||||
"Could not parse conversion service response"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
try:
|
|
||||||
document_content = conversion_response[
|
|
||||||
settings.CONVERSION_API_CONTENT_FIELD
|
|
||||||
]
|
|
||||||
except KeyError as err:
|
|
||||||
raise MissingContentError(
|
|
||||||
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return document_content
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
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(
|
user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
|||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
# 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(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
|||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
# 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(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Test document accesses API endpoints for users in impress's core app.
|
Test document accesses API endpoints for users in impress's core app.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import choices, factories, models
|
||||||
from core.api import serializers
|
from core.api import serializers
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
from core.tests.conftest import TEAM, USER, VIA
|
||||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||||
@@ -51,12 +53,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == []
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_list_unexisting_document():
|
def test_api_document_accesses_list_unexisting_document():
|
||||||
@@ -69,39 +66,46 @@ def test_api_document_accesses_list_unexisting_document():
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert response.json() == {
|
assert response.json() == {"detail": "Not found."}
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
|
"role",
|
||||||
|
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
|
||||||
)
|
)
|
||||||
def test_api_document_accesses_list_authenticated_related_non_privileged(
|
def test_api_document_accesses_list_authenticated_related_non_privileged(
|
||||||
via, role, mock_user_teams
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list document accesses for a document
|
Authenticated users with no privileged role should only be able to list document
|
||||||
to which they are directly related, whatever their role in the document.
|
accesses associated with privileged roles for a document, including from ancestors.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
owner = factories.UserFactory()
|
# Create documents structured as a tree
|
||||||
accesses = []
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
document_access = factories.UserDocumentAccessFactory(
|
grand_parent = factories.DocumentFactory(
|
||||||
user=owner, role=models.RoleChoices.OWNER
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
)
|
)
|
||||||
accesses.append(document_access)
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
document = document_access.document
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
|
# Create accesses related to each document
|
||||||
|
accesses = (
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
)
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
models.DocumentAccess.objects.create(
|
models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
@@ -116,33 +120,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
|||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
|
|
||||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
|
||||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
|
||||||
accesses.append(access1)
|
|
||||||
accesses.append(access2)
|
|
||||||
|
|
||||||
# Accesses for other documents to which the user is related should not be listed either
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(3):
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
)
|
|
||||||
|
|
||||||
# Return only owners
|
|
||||||
owners_accesses = [
|
|
||||||
access for access in accesses if access.role in models.PRIVILEGED_ROLES
|
|
||||||
]
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["count"] == len(owners_accesses)
|
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
# Make sure only privileged roles are returned
|
||||||
|
privileged_accesses = [
|
||||||
|
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
|
||||||
|
]
|
||||||
|
assert len(content) == len(privileged_accesses)
|
||||||
|
|
||||||
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(access.id),
|
"id": str(access.id),
|
||||||
|
"document": {
|
||||||
|
"id": str(access.document_id),
|
||||||
|
"path": access.document.path,
|
||||||
|
"depth": access.document.depth,
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"id": None,
|
|
||||||
"email": None,
|
|
||||||
"full_name": access.user.full_name,
|
"full_name": access.user.full_name,
|
||||||
"short_name": access.user.short_name,
|
"short_name": access.user.short_name,
|
||||||
}
|
}
|
||||||
@@ -150,40 +153,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
|||||||
else None,
|
else None,
|
||||||
"team": access.team,
|
"team": access.team,
|
||||||
"role": access.role,
|
"role": access.role,
|
||||||
"abilities": access.get_abilities(user),
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
|
"abilities": {
|
||||||
|
"destroy": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
"update": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for access in owners_accesses
|
for access in privileged_accesses
|
||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
for access in content["results"]:
|
|
||||||
assert access["role"] in models.PRIVILEGED_ROLES
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
|
@pytest.mark.parametrize(
|
||||||
def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
|
||||||
via, role, mock_user_teams
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_privileged(
|
||||||
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list document accesses for a document
|
Authenticated users with a privileged role should be able to list all
|
||||||
to which they are directly related, whatever their role in the document.
|
document accesses whatever the role, including from ancestors.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
owner = factories.UserFactory()
|
# Create documents structured as a tree
|
||||||
accesses = []
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
document_access = factories.UserDocumentAccessFactory(
|
grand_parent = factories.DocumentFactory(
|
||||||
user=owner, role=models.RoleChoices.OWNER
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
)
|
)
|
||||||
accesses.append(document_access)
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
document = document_access.document
|
document = factories.DocumentFactory(parent=parent)
|
||||||
user_access = None
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
user_access = models.DocumentAccess.objects.create(
|
user_access = models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
@@ -197,61 +207,322 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
|||||||
team="lasuite",
|
team="lasuite",
|
||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError()
|
||||||
|
|
||||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
# Create accesses related to each document
|
||||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
ancestors_accesses = [
|
||||||
accesses.append(access1)
|
# Access on unreadable ancestor should still be listed
|
||||||
accesses.append(access2)
|
# as the related user gains access to our document
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
]
|
||||||
|
document_accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
user_access,
|
||||||
|
]
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
# Accesses for other documents to which the user is related should not be listed either
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(3):
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
)
|
|
||||||
|
|
||||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
|
||||||
base_user = serializers.UserSerializer(instance=user).data
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 4
|
assert len(content) == 7
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(access.id),
|
||||||
"user": base_user if via == "user" else None,
|
"document": {
|
||||||
"team": "lasuite" if via == "team" else "",
|
"id": str(access.document_id),
|
||||||
"role": user_access.role,
|
"path": access.document.path,
|
||||||
"abilities": user_access.get_abilities(user),
|
"depth": access.document.depth,
|
||||||
},
|
},
|
||||||
{
|
"user": {
|
||||||
"id": str(access1.id),
|
"id": str(access.user.id),
|
||||||
"user": None,
|
"email": access.user.email,
|
||||||
"team": access1.team,
|
"language": access.user.language,
|
||||||
"role": access1.role,
|
"full_name": access.user.full_name,
|
||||||
"abilities": access1.get_abilities(user),
|
"short_name": access.user.short_name,
|
||||||
},
|
}
|
||||||
{
|
if access.user
|
||||||
"id": str(access2.id),
|
else None,
|
||||||
"user": access2_user,
|
"max_ancestors_role": None,
|
||||||
"team": "",
|
"max_role": access.role,
|
||||||
"role": access2.role,
|
"team": access.team,
|
||||||
"abilities": access2.get_abilities(user),
|
"role": access.role,
|
||||||
},
|
"abilities": access.get_abilities(user),
|
||||||
{
|
}
|
||||||
"id": str(document_access.id),
|
for access in ancestors_accesses + document_accesses
|
||||||
"user": serializers.UserSerializer(instance=owner).data,
|
|
||||||
"team": "",
|
|
||||||
"role": models.RoleChoices.OWNER,
|
|
||||||
"abilities": document_access.get_abilities(user),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_retrieve_set_role_to_child():
|
||||||
|
"""Check set_role_to for an access with no access on the ancestor."""
|
||||||
|
user, other_user = factories.UserFactory.create_batch(2)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
parent_access = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
document_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role="editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 2
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
|
||||||
|
# Add an access for the other user on the parent
|
||||||
|
parent_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role="commenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 3
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
assert result_dict[str(parent_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given user should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
# Create accesses for another user
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=grand_parent, user=other_user, role=roles[1]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role=roles[2]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "administrator", "reader", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["editor", "editor", "administrator", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
["editor", "administrator"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_team(
|
||||||
|
roles, results, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given team should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
# Create accesses for a team
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=grand_parent, team="lasuite", role=roles[1]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=parent, team="lasuite", role=roles[2]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"] and result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_retrieve_anonymous():
|
def test_api_document_accesses_retrieve_anonymous():
|
||||||
"""
|
"""
|
||||||
Anonymous users should not be allowed to retrieve a document access.
|
Anonymous users should not be allowed to retrieve a document access.
|
||||||
@@ -307,7 +578,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
|
|||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@pytest.mark.parametrize("role", models.RoleChoices)
|
@pytest.mark.parametrize("role", models.RoleChoices)
|
||||||
def test_api_document_accesses_retrieve_authenticated_related(
|
def test_api_document_accesses_retrieve_authenticated_related(
|
||||||
via, role, mock_user_teams
|
via,
|
||||||
|
role,
|
||||||
|
mock_user_teams,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
A user who is related to a document should be allowed to retrieve the
|
A user who is related to a document should be allowed to retrieve the
|
||||||
@@ -333,7 +606,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not role in models.PRIVILEGED_ROLES:
|
if not role in choices.PRIVILEGED_ROLES:
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
else:
|
else:
|
||||||
access_user = serializers.UserSerializer(instance=access.user).data
|
access_user = serializers.UserSerializer(instance=access.user).data
|
||||||
@@ -341,9 +614,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(access.id),
|
"id": str(access.id),
|
||||||
|
"document": {
|
||||||
|
"id": str(access.document_id),
|
||||||
|
"path": access.document.path,
|
||||||
|
"depth": access.document.depth,
|
||||||
|
},
|
||||||
"user": access_user,
|
"user": access_user,
|
||||||
"team": "",
|
"team": "",
|
||||||
"role": access.role,
|
"role": access.role,
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
"abilities": access.get_abilities(user),
|
"abilities": access.get_abilities(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +728,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_administrator_except_owner(
|
def test_api_document_accesses_update_administrator_except_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -481,32 +763,31 @@ def test_api_document_accesses_update_administrator_except_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(["administrator", "editor", "reader"]),
|
"role": random.choice(["administrator", "editor", "reader"]),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if new_data["role"] == old_values["role"]:
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 200
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
@@ -601,7 +882,7 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
# We are not allowed or not really updating the role
|
# We are not allowed or not really updating the role
|
||||||
if field == "role" or new_data["role"] == old_values["role"]:
|
if field == "role":
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
@@ -624,7 +905,9 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_owner(
|
def test_api_document_accesses_update_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -655,42 +938,39 @@ def test_api_document_accesses_update_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(models.RoleChoices.values),
|
"role": random.choice(models.RoleChoices.values),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if (
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
new_data["role"] == old_values["role"]
|
|
||||||
): # we are not really updating the role
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_update_owner_self(
|
def test_api_document_accesses_update_owner_self_root(
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -751,6 +1031,51 @@ def test_api_document_accesses_update_owner_self(
|
|||||||
assert access.role == new_role
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_update_owner_self_child(
|
||||||
|
via,
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user who is owner of a document should be allowed to update
|
||||||
|
their own user access even if they are the only owner in the document,
|
||||||
|
provided the document is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
if via == USER:
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
new_role = random.choice(["administrator", "editor", "reader"])
|
||||||
|
|
||||||
|
user_id = str(access.user_id) if via == USER else None
|
||||||
|
with mock_reset_connections(document.id, user_id):
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
data={**old_values, "role": new_role},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
access.refresh_from_db()
|
||||||
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
|
|
||||||
|
|
||||||
@@ -931,17 +1256,16 @@ def test_api_document_accesses_delete_owners(
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert models.DocumentAccess.objects.count() == 1
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
It should not be possible to delete the last owner access from a document
|
It should not be possible to delete the last owner access from a root document
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory(with_owned_document=True)
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
@@ -964,3 +1288,84 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert models.DocumentAccess.objects.count() == 2
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_user(
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_team(
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that
|
||||||
|
is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
|
|||||||
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
|
def test_api_document_accesses_create_authenticated_administrator_share_to_user(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Administrators of a document should be able to create document accesses
|
Administrators of a document (direct or by heritage) should be able to create
|
||||||
except for the "owner" role.
|
document accesses except for the "owner" role.
|
||||||
An email should be sent to the accesses to notify them of the adding.
|
An email should be sent to the accesses to notify them of the adding.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory(with_owned_document=True)
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(
|
factories.UserDocumentAccessFactory(
|
||||||
document=document, user=user, role="administrator"
|
document=documents[0], user=user, role="administrator"
|
||||||
)
|
)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(
|
factories.TeamDocumentAccessFactory(
|
||||||
document=document, team="lasuite", role="administrator"
|
document=documents[0], team="lasuite", role="administrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
other_user = factories.UserFactory(language="en-us")
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
# It should not be allowed to create an owner access
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
{
|
{
|
||||||
@@ -140,7 +145,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Only owners of a resource can assign other users as owners."
|
"detail": "Only owners of a document can assign other users as owners."
|
||||||
}
|
}
|
||||||
|
|
||||||
# It should be allowed to create a lower access
|
# It should be allowed to create a lower access
|
||||||
@@ -165,9 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
other_user = serializers.UserSerializer(instance=other_user).data
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
"id": str(new_document_access.id),
|
"id": str(new_document_access.id),
|
||||||
"team": "",
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
"role": role,
|
"role": role,
|
||||||
|
"team": "",
|
||||||
"user": other_user,
|
"user": other_user,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == 1
|
assert len(mail.outbox) == 1
|
||||||
@@ -182,28 +194,119 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
assert "docs/" + str(document.id) + "/" in email_content
|
assert "docs/" + str(document.id) + "/" in email_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
def test_api_document_accesses_create_authenticated_administrator_share_to_team(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Owners of a document should be able to create document accesses whatever the role.
|
Administrators of a document (direct or by heritage) should be able to create
|
||||||
|
document accesses except for the "owner" role.
|
||||||
An email should be sent to the accesses to notify them of the adding.
|
An email should be sent to the accesses to notify them of the adding.
|
||||||
"""
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
|
if via == USER:
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="administrator"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=documents[0], team="lasuite", role="administrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Only owners of a document 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"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": role,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
|
||||||
|
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
|
||||||
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
|
assert response.json() == {
|
||||||
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "new-team",
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_create_authenticated_owner_share_to_user(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owners of a document (direct or by heritage) should be able to create document accesses
|
||||||
|
whatever the role. An email should be sent to the accesses to notify them of the adding.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="owner"
|
||||||
|
)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(
|
factories.TeamDocumentAccessFactory(
|
||||||
document=document, team="lasuite", role="owner"
|
document=documents[0], team="lasuite", role="owner"
|
||||||
)
|
)
|
||||||
|
|
||||||
other_user = factories.UserFactory(language="en-us")
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||||
|
|
||||||
assert len(mail.outbox) == 0
|
assert len(mail.outbox) == 0
|
||||||
@@ -222,11 +325,18 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
|||||||
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
||||||
other_user = serializers.UserSerializer(instance=other_user).data
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(new_document_access.id),
|
|
||||||
"user": other_user,
|
|
||||||
"team": "",
|
|
||||||
"role": role,
|
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "",
|
||||||
|
"user": other_user,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == 1
|
assert len(mail.outbox) == 1
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
@@ -240,6 +350,71 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
|||||||
assert "docs/" + str(document.id) + "/" in email_content
|
assert "docs/" + str(document.id) + "/" in email_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_create_authenticated_owner_share_to_team(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owners of a document (direct or by heritage) should be able to create document accesses
|
||||||
|
whatever the role. An email should be sent to the accesses to notify them of the adding.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
|
if via == USER:
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="owner"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=documents[0], team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
|
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": role,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
|
||||||
|
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
|
||||||
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
|
assert response.json() == {
|
||||||
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "new-team",
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
|
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
@@ -286,11 +461,18 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
|
|||||||
).get()
|
).get()
|
||||||
other_user_data = serializers.UserSerializer(instance=other_user).data
|
other_user_data = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(new_document_access.id),
|
|
||||||
"user": other_user_data,
|
|
||||||
"team": "",
|
|
||||||
"role": role,
|
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "",
|
||||||
|
"user": other_user_data,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == index + 1
|
assert len(mail.outbox) == index + 1
|
||||||
email = mail.outbox[index]
|
email = mail.outbox[index]
|
||||||
|
|||||||
@@ -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
|
# Update
|
||||||
|
|
||||||
|
|
||||||
@@ -743,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
|
|||||||
assert value == old_invitation_values[key]
|
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
|
# Delete
|
||||||
|
|
||||||
|
|
||||||
@@ -824,3 +881,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
|
|||||||
response.json()["detail"]
|
response.json()["detail"]
|
||||||
== "You do not have permission to perform this action."
|
== "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
|
||||||
|
|||||||
@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
"formatting. Do not provide any other information. Preserve the language."
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
|
"Preserve the language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": "Hello"},
|
{"role": "user", "content": "Hello"},
|
||||||
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
"formatting. Do not provide any other information. Preserve the language."
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
|
"Preserve the language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": "Hello"},
|
{"role": "user", "content": "Hello"},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Test API for document ask for access."""
|
"""Test API for document ask for access."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ def test_api_documents_ask_for_access_create_authenticated():
|
|||||||
An email should be sent to document owners and admins to notify them.
|
An email should be sent to document owners and admins to notify them.
|
||||||
"""
|
"""
|
||||||
owner_user = UserFactory(language="en-us")
|
owner_user = UserFactory(language="en-us")
|
||||||
admin_user = UserFactory(language="fr-fr")
|
admin_user = UserFactory(language="en-us")
|
||||||
document = DocumentFactory(
|
document = DocumentFactory(
|
||||||
users=[
|
users=[
|
||||||
(owner_user, RoleChoices.OWNER),
|
(owner_user, RoleChoices.OWNER),
|
||||||
@@ -97,7 +98,27 @@ def test_api_documents_ask_for_access_create_authenticated():
|
|||||||
assert document.title.lower() in email_subject.lower()
|
assert document.title.lower() in email_subject.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
def test_api_documents_ask_for_access_create_authenticated_non_root_document():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to create a document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
parent = DocumentFactory()
|
||||||
|
child = DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
Authenticated users should be able to create a document ask for access with a specific role.
|
||||||
"""
|
"""
|
||||||
@@ -109,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
|||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
data={"role": RoleChoices.EDITOR},
|
data={"role": role},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
assert DocumentAskForAccess.objects.filter(
|
assert DocumentAskForAccess.objects.filter(
|
||||||
document=document,
|
document=document,
|
||||||
user=user,
|
user=user,
|
||||||
role=RoleChoices.EDITOR,
|
role=role,
|
||||||
).exists()
|
).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():
|
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."""
|
"""Authenticated users with existing access can ask for access with a different role."""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -196,6 +235,20 @@ def test_api_documents_ask_for_access_list_authenticated():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated_non_root_document():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to list document ask for access on a non-root document.
|
||||||
|
"""
|
||||||
|
parent = DocumentFactory()
|
||||||
|
child = DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_ask_for_access_list_authenticated_own_request():
|
def test_api_documents_ask_for_access_list_authenticated_own_request():
|
||||||
"""Authenticated users should be able to list their own document ask for access."""
|
"""Authenticated users should be able to list their own document ask for access."""
|
||||||
document = DocumentFactory()
|
document = DocumentFactory()
|
||||||
@@ -234,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
|
|||||||
"update": False,
|
"update": False,
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"retrieve": False,
|
"retrieve": False,
|
||||||
|
"set_role_to": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -289,7 +343,7 @@ def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role", [RoleChoices.OWNER])
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
||||||
"""Owner or admin users should be able to list document ask for access."""
|
"""Owner or admin users should be able to list document ask for access."""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -303,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/")
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
assert response.status_code == 200
|
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() == {
|
assert response.json() == {
|
||||||
"count": 3,
|
"count": 3,
|
||||||
"next": None,
|
"next": None,
|
||||||
@@ -322,6 +386,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
|||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
|
"set_role_to": expected_set_role_to,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for document_ask_for_access in document_ask_for_accesses
|
for document_ask_for_access in document_ask_for_accesses
|
||||||
@@ -329,6 +394,23 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_list_admin_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to list document ask for access on a non-root document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
DocumentAskForAccessFactory.create_batch(3, document=child, role=RoleChoices.READER)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
## Retrieve
|
## Retrieve
|
||||||
|
|
||||||
|
|
||||||
@@ -397,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}/"
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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() == {
|
assert response.json() == {
|
||||||
"id": str(document_ask_for_access.id),
|
"id": str(document_ask_for_access.id),
|
||||||
"document": str(document.id),
|
"document": str(document.id),
|
||||||
@@ -411,10 +501,33 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
|
|||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
|
"set_role_to": expected_set_role_to,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to retrieve document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
## Delete
|
## Delete
|
||||||
|
|
||||||
|
|
||||||
@@ -487,6 +600,28 @@ def test_api_documents_ask_for_access_delete_owner_or_admin(role):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to delete document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
## Accept
|
## Accept
|
||||||
|
|
||||||
|
|
||||||
@@ -654,3 +789,104 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
|
|||||||
).exists()
|
).exists()
|
||||||
document_access.refresh_from_db()
|
document_access.refresh_from_db()
|
||||||
assert document_access.role == RoleChoices.ADMIN
|
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):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to accept document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
318
src/backend/core/tests/documents/test_api_documents_can_edit.py
Normal file
318
src/backend/core/tests/documents/test_api_documents_can_edit.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
|
||||||
|
"""Anonymous users can edit documents when link_role is editor."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public", link_role=role)
|
||||||
|
client = APIClient()
|
||||||
|
session_key = client.session.session_key
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
|
||||||
|
|
||||||
|
if role == "reader":
|
||||||
|
assert response.status_code == 401
|
||||||
|
else:
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
|
||||||
|
def test_api_documents_can_edit_authenticated_no_websocket(
|
||||||
|
settings, ws_not_connected_ready_only
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and no other user have already updated the document,
|
||||||
|
the document can be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and another user have already updated the document,
|
||||||
|
the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and another user is connected to the websocket,
|
||||||
|
the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
A user connected to the websocket, the document can be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the document can be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the behavior fallback to the no websocket one.
|
||||||
|
If an other user is already editing, the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_room_not_found(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server returns a 404, the document can be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server returns a 404 and another user is editing the document,
|
||||||
|
the response should be can-edit=False.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
@@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
|
|||||||
if i == 0:
|
if i == 0:
|
||||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||||
else:
|
else:
|
||||||
document = factories.DocumentFactory(parent=document, link_role="reader")
|
document = factories.DocumentFactory(
|
||||||
|
parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
@@ -112,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
|
|||||||
child = Document.objects.get(id=response.json()["id"])
|
child = Document.objects.get(id=response.json()["id"])
|
||||||
assert child.title == "my child"
|
assert child.title == "my child"
|
||||||
assert child.link_reach == "restricted"
|
assert child.link_reach == "restricted"
|
||||||
assert child.accesses.filter(role="owner", user=user).exists()
|
# Access objects on the child are not necessary
|
||||||
|
assert child.accesses.exists() is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("depth", [1, 2, 3])
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@@ -180,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth):
|
|||||||
child = Document.objects.get(id=response.json()["id"])
|
child = Document.objects.get(id=response.json()["id"])
|
||||||
assert child.title == "my child"
|
assert child.title == "my child"
|
||||||
assert child.link_reach == "restricted"
|
assert child.link_reach == "restricted"
|
||||||
assert child.accesses.filter(role="owner", user=user).exists()
|
# Access objects on the child are not necessary
|
||||||
|
assert child.accesses.exists() is False
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_create_authenticated_title_null():
|
def test_api_documents_children_create_authenticated_title_null():
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ from core import factories
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_anonymous_public_standalone():
|
def test_api_documents_children_list_anonymous_public_standalone(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(8):
|
||||||
|
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
with django_assert_num_queries(4):
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -30,8 +35,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -44,12 +54,17 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -62,13 +77,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_anonymous_public_parent():
|
def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries):
|
||||||
"""
|
"""
|
||||||
Anonymous users should be allowed to retrieve the children of a document who
|
Anonymous users should be allowed to retrieve the children of a document who
|
||||||
has a public ancestor.
|
has a public ancestor.
|
||||||
@@ -83,7 +98,10 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(9):
|
||||||
|
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
with django_assert_num_queries(5):
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -93,8 +111,13 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -107,12 +130,17 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -125,7 +153,7 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -149,7 +177,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
|
|||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||||
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
|
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
|
||||||
reach,
|
reach, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to retrieve the children of a public/authenticated
|
Authenticated users should be able to retrieve the children of a public/authenticated
|
||||||
@@ -163,9 +191,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(9):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
)
|
with django_assert_num_queries(5):
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
@@ -174,8 +206,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -188,12 +225,17 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -206,7 +248,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -214,7 +256,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||||
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
|
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
|
||||||
reach,
|
reach, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document who
|
Authenticated users should be allowed to retrieve the children of a document who
|
||||||
@@ -231,7 +273,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(10):
|
||||||
|
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
|
with django_assert_num_queries(6):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -241,8 +287,13 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -255,12 +306,17 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -273,13 +329,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_unrelated_restricted():
|
def test_api_documents_children_list_authenticated_unrelated_restricted(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be allowed to retrieve the children of a document that is
|
Authenticated users should not be allowed to retrieve the children of a document that is
|
||||||
restricted and to which they are not related.
|
restricted and to which they are not related.
|
||||||
@@ -293,16 +351,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted():
|
|||||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(2):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_direct():
|
def test_api_documents_children_list_authenticated_related_direct(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document
|
Authenticated users should be allowed to retrieve the children of a document
|
||||||
to which they are directly related whatever the role.
|
to which they are directly related whatever the role.
|
||||||
@@ -319,10 +381,13 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(9):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
link_role = None if document.link_reach == "restricted" else document.link_role
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
"next": None,
|
"next": None,
|
||||||
@@ -330,8 +395,13 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.link_reach,
|
||||||
|
"ancestors_link_role": link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -344,12 +414,17 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.link_reach,
|
||||||
|
"ancestors_link_role": link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -362,13 +437,15 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_parent():
|
def test_api_documents_children_list_authenticated_related_parent(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document if they
|
Authenticated users should be allowed to retrieve the children of a document if they
|
||||||
are related to one of its ancestors whatever the role.
|
are related to one of its ancestors whatever the role.
|
||||||
@@ -389,9 +466,11 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
document=grand_parent, user=user
|
document=grand_parent, user=user
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(10):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
@@ -400,8 +479,13 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -414,12 +498,17 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -432,13 +521,15 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_child():
|
def test_api_documents_children_list_authenticated_related_child(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be allowed to retrieve all the children of a document
|
Authenticated users should not be allowed to retrieve all the children of a document
|
||||||
as a result of being related to one of its children.
|
as a result of being related to one of its children.
|
||||||
@@ -454,16 +545,20 @@ def test_api_documents_children_list_authenticated_related_child():
|
|||||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||||
factories.UserDocumentAccessFactory(document=document)
|
factories.UserDocumentAccessFactory(document=document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(2):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
|
def test_api_documents_children_list_authenticated_related_team_none(
|
||||||
|
mock_user_teams, django_assert_num_queries
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be able to retrieve the children of a restricted document
|
Authenticated users should not be able to retrieve the children of a restricted document
|
||||||
related to teams in which the user is not.
|
related to teams in which the user is not.
|
||||||
@@ -480,7 +575,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
|
|||||||
|
|
||||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(2):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
@@ -488,7 +585,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
|
|||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_team_members(
|
def test_api_documents_children_list_authenticated_related_team_members(
|
||||||
mock_user_teams,
|
mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document to which they
|
Authenticated users should be allowed to retrieve the children of a document to which they
|
||||||
@@ -506,7 +603,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
|
|
||||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(9):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
# pylint: disable=R0801
|
# pylint: disable=R0801
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -517,8 +615,13 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -531,12 +634,17 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -549,7 +657,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
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 pytest
|
||||||
import responses
|
import responses
|
||||||
|
from requests.exceptions import RequestException
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
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}"
|
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||||
)
|
)
|
||||||
assert response.status_code == 415
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ pytestmark = pytest.mark.django_db
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_convert_md():
|
def mock_convert_md():
|
||||||
"""Mock YdocConverter.convert_markdown to return a converted content."""
|
"""Mock YdocConverter.convert to return a converted content."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
YdocConverter,
|
YdocConverter,
|
||||||
"convert_markdown",
|
"convert",
|
||||||
return_value="Converted document content",
|
return_value="Converted document content",
|
||||||
) as mock:
|
) as mock:
|
||||||
yield mock
|
yield mock
|
||||||
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
|
|||||||
data = {
|
data = {
|
||||||
"title": "My Document",
|
"title": "My Document",
|
||||||
"content": "Document content",
|
"content": "Document content",
|
||||||
"sub": "123!!",
|
"sub": "invalid süb",
|
||||||
"email": "john.doe@example.com",
|
"email": "john.doe@example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +163,7 @@ def test_api_documents_create_for_owner_invalid_sub():
|
|||||||
assert not Document.objects.exists()
|
assert not Document.objects.exists()
|
||||||
|
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"sub": [
|
"sub": ["Enter a valid sub. This value should be ASCII only."]
|
||||||
"Enter a valid sub. This value may contain only letters, "
|
|
||||||
"numbers, and @/./+/-/_/: characters."
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -46,12 +51,19 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": "editor"
|
||||||
|
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||||
|
else document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -64,12 +76,17 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -82,7 +99,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -115,8 +132,13 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -129,12 +151,17 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -147,12 +174,17 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -165,7 +197,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -201,7 +233,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory(link_reach=reach)
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(
|
||||||
|
2, parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
grand_child = factories.DocumentFactory(parent=child1)
|
grand_child = factories.DocumentFactory(parent=child1)
|
||||||
|
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
@@ -217,8 +251,13 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -231,12 +270,17 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -249,12 +293,17 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -267,7 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -289,7 +338,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(
|
||||||
|
2, parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
grand_child = factories.DocumentFactory(parent=child1)
|
grand_child = factories.DocumentFactory(parent=child1)
|
||||||
|
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
@@ -304,8 +355,13 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -318,12 +374,17 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -336,12 +397,17 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -354,7 +420,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -414,8 +480,13 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -428,12 +499,17 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -446,12 +522,17 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -464,7 +545,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -504,8 +585,13 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -518,12 +604,17 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -536,12 +627,17 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -554,7 +650,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -640,8 +736,13 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child1.excerpt,
|
"excerpt": child1.excerpt,
|
||||||
"id": str(child1.id),
|
"id": str(child1.id),
|
||||||
@@ -654,12 +755,17 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
"excerpt": grand_child.excerpt,
|
"excerpt": grand_child.excerpt,
|
||||||
"id": str(grand_child.id),
|
"id": str(grand_child.id),
|
||||||
@@ -672,12 +778,17 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
"excerpt": child2.excerpt,
|
"excerpt": child2.excerpt,
|
||||||
"id": str(child2.id),
|
"id": str(child2.id),
|
||||||
@@ -690,7 +801,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.utils import timezone
|
|||||||
import pycrdt
|
import pycrdt
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -60,7 +61,7 @@ def test_api_documents_duplicate_forbidden():
|
|||||||
def test_api_documents_duplicate_anonymous():
|
def test_api_documents_duplicate_anonymous():
|
||||||
"""Anonymous users should not be able to duplicate documents even with read access."""
|
"""Anonymous users should not be able to duplicate documents even with read access."""
|
||||||
|
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
document = factories.DocumentFactory(link_reach="public", link_role="reader")
|
||||||
|
|
||||||
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
|
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
|
||||||
|
|
||||||
@@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index):
|
|||||||
|
|
||||||
# Ensure access persists after the owner loses access to the original document
|
# Ensure access persists after the owner loses access to the original document
|
||||||
models.DocumentAccess.objects.filter(document=document).delete()
|
models.DocumentAccess.objects.filter(document=document).delete()
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
|
now = timezone.now()
|
||||||
)
|
with freeze_time(now):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
authorization = response["Authorization"]
|
authorization = response["Authorization"]
|
||||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||||
assert (
|
assert (
|
||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -168,14 +171,17 @@ def test_api_documents_duplicate_success(index):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_duplicate_with_accesses():
|
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||||
"""Accesses should be duplicated if the user requests it specifically."""
|
def test_api_documents_duplicate_with_accesses_admin(role):
|
||||||
|
"""
|
||||||
|
Accesses should be duplicated if the user requests it specifically and is owner or admin.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory(
|
document = factories.DocumentFactory(
|
||||||
users=[user],
|
users=[(user, role)],
|
||||||
title="document with accesses",
|
title="document with accesses",
|
||||||
)
|
)
|
||||||
user_access = factories.UserDocumentAccessFactory(document=document)
|
user_access = factories.UserDocumentAccessFactory(document=document)
|
||||||
@@ -205,3 +211,110 @@ def test_api_documents_duplicate_with_accesses():
|
|||||||
assert duplicated_accesses.get(user=user).role == "owner"
|
assert duplicated_accesses.get(user=user).role == "owner"
|
||||||
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
|
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
|
||||||
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
|
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_duplicate_with_accesses_non_admin(role):
|
||||||
|
"""
|
||||||
|
Accesses should not be duplicated if the user requests it specifically and is not owner
|
||||||
|
or admin.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
users=[(user, role)],
|
||||||
|
title="document with accesses",
|
||||||
|
)
|
||||||
|
factories.UserDocumentAccessFactory(document=document)
|
||||||
|
factories.TeamDocumentAccessFactory(document=document)
|
||||||
|
|
||||||
|
# Duplicate the document via the API endpoint requesting to duplicate accesses
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/duplicate/",
|
||||||
|
{"with_accesses": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||||
|
assert duplicated_document.title == "Copy of document with accesses"
|
||||||
|
assert duplicated_document.content == document.content
|
||||||
|
assert duplicated_document.link_reach == document.link_reach
|
||||||
|
assert duplicated_document.link_role == document.link_role
|
||||||
|
assert duplicated_document.creator == user
|
||||||
|
assert duplicated_document.duplicated_from == document
|
||||||
|
assert duplicated_document.attachments == []
|
||||||
|
|
||||||
|
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
|
||||||
|
duplicated_accesses = duplicated_document.accesses
|
||||||
|
assert duplicated_accesses.count() == 1
|
||||||
|
assert duplicated_accesses.get(user=user).role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_duplicate_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Non-root documents can be duplicated but without accesses.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||||
|
child = factories.DocumentFactory(
|
||||||
|
parent=document, users=[(user, role)], title="document with accesses"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert child.accesses.count() == 1
|
||||||
|
|
||||||
|
# Duplicate the document via the API endpoint requesting to duplicate accesses
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{child.id!s}/duplicate/",
|
||||||
|
{"with_accesses": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||||
|
assert duplicated_document.title == "Copy of document with accesses"
|
||||||
|
assert duplicated_document.content == child.content
|
||||||
|
assert duplicated_document.link_reach == child.link_reach
|
||||||
|
assert duplicated_document.link_role == child.link_role
|
||||||
|
assert duplicated_document.creator == user
|
||||||
|
assert duplicated_document.duplicated_from == child
|
||||||
|
assert duplicated_document.attachments == []
|
||||||
|
|
||||||
|
# No access should be created for non root documents
|
||||||
|
duplicated_accesses = duplicated_document.accesses
|
||||||
|
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):
|
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()
|
user = factories.UserFactory()
|
||||||
document = factories.DocumentFactory(link_reach=reach)
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
client = APIClient()
|
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():
|
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()
|
user = factories.UserFactory()
|
||||||
document = factories.DocumentFactory(link_reach="restricted")
|
document = factories.DocumentFactory(link_reach="restricted")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
# User don't have access to this document, let say it had access and this access has been
|
# If the user doesn't have access to this document (e.g the user had access
|
||||||
# removed. It should not be in the favorite list anymore.
|
# and this access was removed), it should not be in the favorite list anymore.
|
||||||
factories.DocumentFactory(favorited_by=[user])
|
factories.DocumentFactory(favorited_by=[user])
|
||||||
|
|
||||||
document = factories.UserDocumentAccessFactory(
|
document = factories.UserDocumentAccessFactory(
|
||||||
@@ -59,8 +59,13 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"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"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"depth": document.depth,
|
"depth": document.depth,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
@@ -74,7 +79,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": ["reader"],
|
"user_role": "reader",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
document = factories.DocumentFactory(
|
||||||
|
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||||
|
link_role=models.LinkRoleChoices.READER,
|
||||||
|
)
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
new_document_values = serializers.LinkDocumentSerializer(
|
new_document_values = serializers.LinkDocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory(
|
||||||
|
link_reach=models.LinkReachChoices.PUBLIC,
|
||||||
|
link_role=models.LinkRoleChoices.EDITOR,
|
||||||
|
)
|
||||||
).data
|
).data
|
||||||
|
|
||||||
with mock_reset_connections(document.id):
|
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
|
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||||
for key, value in document_values.items():
|
for key, value in document_values.items():
|
||||||
assert value == new_document_values[key]
|
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
|
||||||
|
|||||||
@@ -63,8 +63,13 @@ def test_api_documents_list_format():
|
|||||||
assert results[0] == {
|
assert results[0] == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"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"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": True,
|
"is_favorite": True,
|
||||||
@@ -76,7 +81,7 @@ def test_api_documents_list_format():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,11 +153,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
|||||||
str(child4_with_access.id),
|
str(child4_with_access.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
with django_assert_num_queries(12):
|
with django_assert_num_queries(14):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
# nb_accesses should now be cached
|
# nb_accesses should now be cached
|
||||||
with django_assert_num_queries(4):
|
with django_assert_num_queries(6):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -268,11 +273,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
|||||||
|
|
||||||
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
||||||
|
|
||||||
with django_assert_num_queries(10):
|
with django_assert_num_queries(11):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
# nb_accesses should now be cached
|
# nb_accesses should now be cached
|
||||||
with django_assert_num_queries(4):
|
with django_assert_num_queries(5):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -423,3 +428,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
|
|||||||
assert result["is_favorite"] is True
|
assert result["is_favorite"] is True
|
||||||
else:
|
else:
|
||||||
assert result["is_favorite"] is False
|
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
|
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
|
# 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()
|
||||||
@@ -12,6 +12,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -52,9 +53,11 @@ def test_api_documents_media_auth_anonymous_public():
|
|||||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{key:s}"
|
original_url = f"http://localhost/media/{key:s}"
|
||||||
response = APIClient().get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
with freeze_time(now):
|
||||||
)
|
response = APIClient().get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ def test_api_documents_media_auth_anonymous_public():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -167,9 +170,11 @@ def test_api_documents_media_auth_anonymous_attachments():
|
|||||||
parent = factories.DocumentFactory(link_reach="public")
|
parent = factories.DocumentFactory(link_reach="public")
|
||||||
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
|
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
|
||||||
|
|
||||||
response = APIClient().get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = APIClient().get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -179,7 +184,7 @@ def test_api_documents_media_auth_anonymous_attachments():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -221,9 +226,11 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
|||||||
|
|
||||||
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
|
||||||
|
|
||||||
response = client.get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -233,7 +240,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -307,9 +314,11 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
|||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||||
|
|
||||||
response = client.get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -319,7 +328,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -373,10 +382,12 @@ def test_api_documents_media_auth_missing_status_metadata():
|
|||||||
|
|
||||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
original_url = f"http://localhost/media/{key:s}"
|
original_url = f"http://localhost/media/{key:s}"
|
||||||
response = APIClient().get(
|
with freeze_time(now):
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
response = APIClient().get(
|
||||||
)
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -386,7 +397,7 @@ def test_api_documents_media_auth_missing_status_metadata():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
|
|||||||
target_role, target_parent_role, position
|
target_role, target_parent_role, position
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users with insufficient permissions on the target document (or its
|
Only authenticated users with sufficient permissions on the target document (or its
|
||||||
parent depending on the position chosen), should not be allowed to move documents.
|
parent depending on the position chosen), should be allowed to move documents.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
|
|||||||
assert document.is_root() is True
|
assert document.is_root() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_user_and_team():
|
||||||
|
"""
|
||||||
|
Moving a document with no owner to the root of the tree should automatically declare
|
||||||
|
the owner of the previous root of the document as owner of the document itself.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent_owner = factories.UserFactory()
|
||||||
|
parent = factories.DocumentFactory(
|
||||||
|
users=[(parent_owner, "owner")], teams=[("lasuite", "owner")]
|
||||||
|
)
|
||||||
|
# A document with no owner
|
||||||
|
document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")])
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 3
|
||||||
|
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
assert document.accesses.get(role="administrator").user == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_same_user():
|
||||||
|
"""
|
||||||
|
Moving a document should not fail if the user moving a document with no owner was
|
||||||
|
at the same time owner of the previous root and has a role on the document being moved.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory(
|
||||||
|
users=[(user, "owner")], teams=[("lasuite", "owner")]
|
||||||
|
)
|
||||||
|
# A document with no owner
|
||||||
|
document = factories.DocumentFactory(parent=parent, users=[(user, "reader")])
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 2
|
||||||
|
assert document.accesses.get(user__isnull=False, role="owner").user == user
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_same_team():
|
||||||
|
"""
|
||||||
|
Moving a document should not fail if the team that is owner of the document root was
|
||||||
|
already declared on the document with a different role.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory(teams=[("lasuite", "owner")])
|
||||||
|
# A document with no owner but same team
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")]
|
||||||
|
)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 2
|
||||||
|
assert document.accesses.get(user__isnull=False, role="administrator").user == user
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_move_authenticated_deleted_document():
|
def test_api_documents_move_authenticated_deleted_document():
|
||||||
"""
|
"""
|
||||||
It should not be possible to move a deleted document or its descendants, even
|
It should not be possible to move a deleted document or its descendants, even
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for Documents API endpoint in impress's core app: retrieve
|
Tests for Documents API endpoint in impress's core app: retrieve
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -11,7 +12,7 @@ from django.utils import timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import choices, factories, models
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -31,22 +32,26 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": document.link_role == "editor",
|
"attachment_upload": document.link_role == "editor",
|
||||||
|
"can_edit": document.link_role == "editor",
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": document.link_role in ["commenter", "editor"],
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": False,
|
||||||
# Anonymous user can't favorite a document even with read access
|
# Anonymous user can't favorite a document even with read access
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -59,9 +64,14 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -73,7 +83,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +101,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
links_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -99,18 +110,24 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": grand_parent.link_role == "editor",
|
"attachment_upload": grand_parent.link_role == "editor",
|
||||||
|
"can_edit": grand_parent.link_role == "editor",
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": False,
|
||||||
# Anonymous user can't favorite a document even with read access
|
# Anonymous user can't favorite a document even with read access
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**links_definition
|
||||||
|
),
|
||||||
|
"mask": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -123,9 +140,14 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_parent.link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -137,7 +159,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,21 +218,25 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"ai_transform": document.link_role == "editor",
|
"ai_transform": document.link_role == "editor",
|
||||||
"ai_translate": document.link_role == "editor",
|
"ai_translate": document.link_role == "editor",
|
||||||
"attachment_upload": document.link_role == "editor",
|
"attachment_upload": document.link_role == "editor",
|
||||||
|
"can_edit": document.link_role == "editor",
|
||||||
"children_create": document.link_role == "editor",
|
"children_create": document.link_role == "editor",
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": document.link_role in ["commenter", "editor"],
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -223,10 +249,15 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
"link_reach": reach,
|
"link_reach": reach,
|
||||||
@@ -237,7 +268,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
assert (
|
assert (
|
||||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||||
@@ -263,6 +294,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
links_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -271,20 +303,26 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"ai_transform": grand_parent.link_role == "editor",
|
"ai_transform": grand_parent.link_role == "editor",
|
||||||
"ai_translate": grand_parent.link_role == "editor",
|
"ai_translate": grand_parent.link_role == "editor",
|
||||||
"attachment_upload": grand_parent.link_role == "editor",
|
"attachment_upload": grand_parent.link_role == "editor",
|
||||||
|
"can_edit": grand_parent.link_role == "editor",
|
||||||
"children_create": grand_parent.link_role == "editor",
|
"children_create": grand_parent.link_role == "editor",
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**links_definition
|
||||||
|
),
|
||||||
|
"mask": True,
|
||||||
|
"move": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
|
||||||
"partial_update": grand_parent.link_role == "editor",
|
"partial_update": grand_parent.link_role == "editor",
|
||||||
"restore": False,
|
"restore": False,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
@@ -294,10 +332,15 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
"link_reach": document.link_reach,
|
"link_reach": document.link_reach,
|
||||||
@@ -308,7 +351,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -404,9 +447,14 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"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,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -418,7 +466,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,41 +492,53 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
link_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"accesses_manage": access.role in ["administrator", "owner"],
|
"accesses_manage": access.role in ["administrator", "owner"],
|
||||||
"accesses_view": True,
|
"accesses_view": True,
|
||||||
"ai_transform": access.role != "reader",
|
"ai_transform": access.role not in ["reader", "commenter"],
|
||||||
"ai_translate": access.role != "reader",
|
"ai_translate": access.role not in ["reader", "commenter"],
|
||||||
"attachment_upload": access.role != "reader",
|
"attachment_upload": access.role not in ["reader", "commenter"],
|
||||||
"children_create": access.role != "reader",
|
"can_edit": access.role not in ["reader", "commenter"],
|
||||||
|
"children_create": access.role not in ["reader", "commenter"],
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": access.role != "reader",
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
"destroy": access.role == "owner",
|
"content": True,
|
||||||
|
"destroy": access.role in ["administrator", "owner"],
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": access.role == "owner",
|
"invite_owner": access.role == "owner",
|
||||||
"link_configuration": access.role in ["administrator", "owner"],
|
"link_configuration": access.role in ["administrator", "owner"],
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**link_definition
|
||||||
|
),
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": access.role in ["administrator", "owner"],
|
"move": access.role in ["administrator", "owner"],
|
||||||
"partial_update": access.role != "reader",
|
"partial_update": access.role not in ["reader", "commenter"],
|
||||||
"restore": access.role == "owner",
|
"restore": access.role == "owner",
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"tree": True,
|
"tree": True,
|
||||||
"update": access.role != "reader",
|
"update": access.role not in ["reader", "commenter"],
|
||||||
"versions_destroy": access.role in ["administrator", "owner"],
|
"versions_destroy": access.role in ["administrator", "owner"],
|
||||||
"versions_list": True,
|
"versions_list": True,
|
||||||
"versions_retrieve": True,
|
"versions_retrieve": True,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": "restricted",
|
||||||
|
"computed_link_role": None,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
"link_reach": "restricted",
|
"link_reach": "restricted",
|
||||||
@@ -489,7 +549,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -585,16 +645,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["readers"], ["reader"]],
|
[["readers"], "reader"],
|
||||||
[["unknown", "readers"], ["reader"]],
|
[["unknown", "readers"], "reader"],
|
||||||
[["editors"], ["editor"]],
|
[["editors"], "editor"],
|
||||||
[["unknown", "editors"], ["editor"]],
|
[["unknown", "editors"], "editor"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_members(
|
def test_api_documents_retrieve_authenticated_related_team_members(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a document to which they
|
Authenticated users should be allowed to retrieve a document to which they
|
||||||
@@ -627,9 +687,14 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"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,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -641,20 +706,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["administrators"], ["administrator"]],
|
[["administrators"], "administrator"],
|
||||||
[["editors", "administrators"], ["administrator", "editor"]],
|
[["editors", "administrators"], "administrator"],
|
||||||
[["unknown", "administrators"], ["administrator"]],
|
[["unknown", "administrators"], "administrator"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_administrators(
|
def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a document to which they
|
Authenticated users should be allowed to retrieve a document to which they
|
||||||
@@ -689,9 +754,14 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"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,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -703,21 +773,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["owners"], ["owner"]],
|
[["owners"], "owner"],
|
||||||
[["owners", "administrators"], ["owner", "administrator"]],
|
[["owners", "administrators"], "owner"],
|
||||||
[["members", "administrators", "owners"], ["owner", "administrator"]],
|
[["members", "administrators", "owners"], "owner"],
|
||||||
[["unknown", "owners"], ["owner"]],
|
[["unknown", "owners"], "owner"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_owners(
|
def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a restricted document to which
|
Authenticated users should be allowed to retrieve a restricted document to which
|
||||||
@@ -751,9 +821,14 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"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,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
|
"deleted_at": None,
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -765,11 +840,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
def test_api_documents_retrieve_user_role(django_assert_max_num_queries):
|
||||||
"""
|
"""
|
||||||
Roles should be annotated on querysets taking into account all documents ancestors.
|
Roles should be annotated on querysets taking into account all documents ancestors.
|
||||||
"""
|
"""
|
||||||
@@ -792,15 +867,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
|||||||
factories.UserDocumentAccessFactory(document=parent, user=user),
|
factories.UserDocumentAccessFactory(document=parent, user=user),
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user),
|
factories.UserDocumentAccessFactory(document=document, user=user),
|
||||||
)
|
)
|
||||||
expected_roles = {access.role for access in accesses}
|
expected_role = choices.RoleChoices.max(*[access.role for access in accesses])
|
||||||
|
|
||||||
with django_assert_max_num_queries(14):
|
with django_assert_max_num_queries(14):
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
user_roles = response.json()["user_roles"]
|
assert response.json()["user_role"] == expected_role
|
||||||
assert set(user_roles) == expected_roles
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
|
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
|
||||||
|
|||||||
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)
|
other_users = factories.UserFactory.create_batch(3)
|
||||||
document = factories.DocumentFactory(
|
document = factories.DocumentFactory(
|
||||||
deleted_at=timezone.now(),
|
|
||||||
users=factories.UserFactory.create_batch(2),
|
users=factories.UserFactory.create_batch(2),
|
||||||
favorited_by=[user, *other_users],
|
favorited_by=[user, *other_users],
|
||||||
link_traces=other_users,
|
link_traces=other_users,
|
||||||
)
|
)
|
||||||
|
document.soft_delete()
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||||
|
|
||||||
response = client.get("/api/v1.0/documents/trashbin/")
|
response = client.get("/api/v1.0/documents/trashbin/")
|
||||||
@@ -70,42 +70,51 @@ def test_api_documents_trashbin_format():
|
|||||||
assert results[0] == {
|
assert results[0] == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"accesses_manage": True,
|
"accesses_manage": False,
|
||||||
"accesses_view": True,
|
"accesses_view": False,
|
||||||
"ai_transform": True,
|
"ai_transform": False,
|
||||||
"ai_translate": True,
|
"ai_translate": False,
|
||||||
"attachment_upload": True,
|
"attachment_upload": False,
|
||||||
"children_create": True,
|
"can_edit": False,
|
||||||
"children_list": True,
|
"children_create": False,
|
||||||
"collaboration_auth": True,
|
"children_list": False,
|
||||||
"descendants": True,
|
"collaboration_auth": False,
|
||||||
"cors_proxy": True,
|
"descendants": False,
|
||||||
"destroy": True,
|
"cors_proxy": False,
|
||||||
"duplicate": True,
|
"comment": False,
|
||||||
"favorite": True,
|
"content": False,
|
||||||
"invite_owner": True,
|
"destroy": False,
|
||||||
"link_configuration": True,
|
"duplicate": False,
|
||||||
|
"favorite": False,
|
||||||
|
"invite_owner": False,
|
||||||
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
"media_auth": True,
|
"mask": False,
|
||||||
"media_check": True,
|
"media_auth": False,
|
||||||
|
"media_check": False,
|
||||||
"move": False, # Can't move a deleted document
|
"move": False, # Can't move a deleted document
|
||||||
"partial_update": True,
|
"partial_update": False,
|
||||||
"restore": True,
|
"restore": True,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"tree": True,
|
"tree": True,
|
||||||
"update": True,
|
"update": False,
|
||||||
"versions_destroy": True,
|
"versions_destroy": False,
|
||||||
"versions_list": True,
|
"versions_list": False,
|
||||||
"versions_retrieve": True,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"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"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
|
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
|
||||||
"link_reach": document.link_reach,
|
"link_reach": document.link_reach,
|
||||||
"link_role": document.link_role,
|
"link_role": document.link_role,
|
||||||
"nb_accesses_ancestors": 0,
|
"nb_accesses_ancestors": 0,
|
||||||
@@ -114,7 +123,7 @@ def test_api_documents_trashbin_format():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": ["owner"],
|
"user_role": "owner",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -158,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)}
|
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/")
|
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/")
|
response = client.get("/api/v1.0/documents/trashbin/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -200,10 +209,10 @@ def test_api_documents_trashbin_authenticated_via_team(
|
|||||||
|
|
||||||
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
|
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/")
|
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/")
|
response = client.get("/api/v1.0/documents/trashbin/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -285,3 +294,29 @@ def test_api_documents_trashbin_distinct():
|
|||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 1
|
assert len(content["results"]) == 1
|
||||||
assert content["results"][0]["id"] == str(document.id)
|
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
|
||||||
|
|||||||
@@ -32,18 +32,25 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(AnonymousUser()),
|
"abilities": parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(AnonymousUser()),
|
"abilities": document.get_abilities(AnonymousUser()),
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(AnonymousUser()),
|
"abilities": child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -57,12 +64,17 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -74,14 +86,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling1.get_abilities(AnonymousUser()),
|
"abilities": sibling1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": sibling1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling1.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling1.computed_link_reach,
|
||||||
|
"computed_link_role": sibling1.computed_link_role,
|
||||||
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling1.creator.id),
|
"creator": str(sibling1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": sibling1.excerpt,
|
"excerpt": sibling1.excerpt,
|
||||||
"id": str(sibling1.id),
|
"id": str(sibling1.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -93,14 +110,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": sibling1.path,
|
"path": sibling1.path,
|
||||||
"title": sibling1.title,
|
"title": sibling1.title,
|
||||||
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling2.get_abilities(AnonymousUser()),
|
"abilities": sibling2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": sibling2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling2.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling2.computed_link_reach,
|
||||||
|
"computed_link_role": sibling2.computed_link_role,
|
||||||
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling2.creator.id),
|
"creator": str(sibling2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": sibling2.excerpt,
|
"excerpt": sibling2.excerpt,
|
||||||
"id": str(sibling2.id),
|
"id": str(sibling2.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -112,12 +134,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": sibling2.path,
|
"path": sibling2.path,
|
||||||
"title": sibling2.title,
|
"title": sibling2.title,
|
||||||
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -129,7 +154,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,23 +188,34 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
expected_tree = {
|
||||||
"abilities": grand_parent.get_abilities(AnonymousUser()),
|
"abilities": grand_parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(AnonymousUser()),
|
"abilities": parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(AnonymousUser()),
|
"abilities": document.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(AnonymousUser()),
|
"abilities": child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -193,14 +229,17 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -214,16 +253,21 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(AnonymousUser()),
|
"abilities": document_sibling.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document_sibling.creator.id),
|
"creator": str(document_sibling.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document_sibling.excerpt,
|
"excerpt": document_sibling.excerpt,
|
||||||
"id": str(document_sibling.id),
|
"id": str(document_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -237,12 +281,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -254,16 +301,21 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(AnonymousUser()),
|
"abilities": parent_sibling.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(parent_sibling.creator.id),
|
"creator": str(parent_sibling.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent_sibling.excerpt,
|
"excerpt": parent_sibling.excerpt,
|
||||||
"id": str(parent_sibling.id),
|
"id": str(parent_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -277,12 +329,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": grand_parent.excerpt,
|
"excerpt": grand_parent.excerpt,
|
||||||
"id": str(grand_parent.id),
|
"id": str(grand_parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -294,8 +349,9 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
assert response.json() == expected_tree
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||||
@@ -341,18 +397,27 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -366,12 +431,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_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"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -383,14 +451,19 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": sibling.excerpt,
|
"excerpt": sibling.excerpt,
|
||||||
"id": str(sibling.id),
|
"id": str(sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -402,12 +475,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -419,7 +495,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -460,21 +536,32 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": grand_parent.get_abilities(user),
|
"abilities": grand_parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -488,14 +575,17 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -509,16 +599,21 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(user),
|
"abilities": document_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document_sibling.creator.id),
|
"creator": str(document_sibling.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document_sibling.excerpt,
|
"excerpt": document_sibling.excerpt,
|
||||||
"id": str(document_sibling.id),
|
"id": str(document_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -532,12 +627,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -549,16 +647,21 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(user),
|
"abilities": parent_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(parent_sibling.creator.id),
|
"creator": str(parent_sibling.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent_sibling.excerpt,
|
"excerpt": parent_sibling.excerpt,
|
||||||
"id": str(parent_sibling.id),
|
"id": str(parent_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -572,12 +675,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": grand_parent.excerpt,
|
"excerpt": grand_parent.excerpt,
|
||||||
"id": str(grand_parent.id),
|
"id": str(grand_parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -589,7 +695,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -639,18 +745,27 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -664,12 +779,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -681,14 +799,19 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": sibling.excerpt,
|
"excerpt": sibling.excerpt,
|
||||||
"id": str(sibling.id),
|
"id": str(sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -700,12 +823,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -717,7 +843,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -762,21 +888,32 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": grand_parent.get_abilities(user),
|
"abilities": grand_parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -790,14 +927,17 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -811,16 +951,21 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(user),
|
"abilities": document_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(document_sibling.creator.id),
|
"creator": str(document_sibling.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document_sibling.excerpt,
|
"excerpt": document_sibling.excerpt,
|
||||||
"id": str(document_sibling.id),
|
"id": str(document_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -834,12 +979,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -851,16 +999,21 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(user),
|
"abilities": parent_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(parent_sibling.creator.id),
|
"creator": str(parent_sibling.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent_sibling.excerpt,
|
"excerpt": parent_sibling.excerpt,
|
||||||
"id": str(parent_sibling.id),
|
"id": str(parent_sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -874,12 +1027,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": grand_parent.excerpt,
|
"excerpt": grand_parent.excerpt,
|
||||||
"id": str(grand_parent.id),
|
"id": str(grand_parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -891,7 +1047,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -949,18 +1105,27 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"creator": str(child.creator.id),
|
"creator": str(child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": child.excerpt,
|
"excerpt": child.excerpt,
|
||||||
"id": str(child.id),
|
"id": str(child.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -974,12 +1139,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": document.excerpt,
|
"excerpt": document.excerpt,
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -991,14 +1159,19 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": sibling.excerpt,
|
"excerpt": sibling.excerpt,
|
||||||
"id": str(sibling.id),
|
"id": str(sibling.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -1010,12 +1183,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
|
"deleted_at": None,
|
||||||
"excerpt": parent.excerpt,
|
"excerpt": parent.excerpt,
|
||||||
"id": str(parent.id),
|
"id": str(parent.id),
|
||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
@@ -1027,5 +1203,58 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"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")
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ Tests for Documents API endpoint in impress's core app: update
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import responses
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
|
|||||||
|
|
||||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory(),
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
|||||||
|
|
||||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory(),
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -155,6 +160,10 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
|||||||
for key, value in document_values.items():
|
for key, value in document_values.items():
|
||||||
if key in [
|
if key in [
|
||||||
"id",
|
"id",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"accesses",
|
"accesses",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
@@ -206,6 +215,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -258,6 +268,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -270,6 +281,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
for key, value in document_values.items():
|
for key, value in document_values.items():
|
||||||
if key in [
|
if key in [
|
||||||
"id",
|
"id",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
@@ -287,6 +302,359 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
assert value == new_document_values[key]
|
assert value == new_document_values[key]
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_authenticated_no_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and is the first to update,
|
||||||
|
the document should be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and is not the first to update,
|
||||||
|
the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and another user is connected
|
||||||
|
to the websocket, the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, connected to the websocket, the document should be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the document should be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the behavior fallback to the no websocket one.
|
||||||
|
If an other user is already editing, the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the WebSocket server does not have the room created, the logic should fallback to
|
||||||
|
no-WebSocket. If another user is already editing, the update must be denied.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_force_websocket_param_to_true(settings):
|
||||||
|
"""
|
||||||
|
When the websocket parameter is set to true, the document should be updated without any check.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_feature_flag_disabled(settings):
|
||||||
|
"""
|
||||||
|
When the feature flag is disabled, the document should be updated without any check.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
|
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
@@ -317,6 +685,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{other_document.id!s}/",
|
f"/api/v1.0/documents/{other_document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
|||||||
with django_assert_num_queries(11):
|
with django_assert_num_queries(11):
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys)},
|
{"content": get_ydoc_with_mages(image_keys), "websocket": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
|||||||
with django_assert_num_queries(7):
|
with django_assert_num_queries(7):
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys[:2])},
|
{"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -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,780 +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() == {
|
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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["results"]) == 3
|
|
||||||
assert sorted(content["results"], 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 resource 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",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 405
|
||||||
template = Template.objects.get()
|
assert not Template.objects.exists()
|
||||||
assert template.title == "my template"
|
|
||||||
assert template.accesses.filter(role="owner", user=user).exists()
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import pytest
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
|
|||||||
assert models.Template.objects.count() == 1
|
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
|
Authenticated users should not be allowed to delete a template to which they are not
|
||||||
related.
|
related.
|
||||||
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
is_public = random.choice([True, False])
|
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(
|
response = client.delete(
|
||||||
f"/api/v1.0/templates/{template.id!s}/",
|
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
|
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, (
|
assert response_template_ids == templates_ids, (
|
||||||
"created_at values are not sorted from oldest to newest"
|
"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
|
||||||
|
|||||||
@@ -2,14 +2,11 @@
|
|||||||
Tests for Templates API endpoint in impress's core app: update
|
Tests for Templates API endpoint in impress's core app: update
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
from core.api import serializers
|
from core.api import serializers
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -17,7 +14,6 @@ pytestmark = pytest.mark.django_db
|
|||||||
def test_api_templates_update_anonymous():
|
def test_api_templates_update_anonymous():
|
||||||
"""Anonymous users should not be allowed to update a template."""
|
"""Anonymous users should not be allowed to update a template."""
|
||||||
template = factories.TemplateFactory()
|
template = factories.TemplateFactory()
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
new_template_values = serializers.TemplateSerializer(
|
||||||
instance=factories.TemplateFactory()
|
instance=factories.TemplateFactory()
|
||||||
@@ -28,145 +24,18 @@ def test_api_templates_update_anonymous():
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json() == {
|
|
||||||
"detail": "Authentication credentials were not provided."
|
|
||||||
}
|
|
||||||
|
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
assert template_values == old_template_values
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_templates_update_authenticated_unrelated():
|
def test_api_templates_update_not_implemented():
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be allowed to update a template to which they are not related.
|
Authenticated users should not be allowed to update a template.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
template = factories.TemplateFactory(is_public=False)
|
template = factories.TemplateFactory(users=[(user, "owner")])
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
|
||||||
instance=factories.TemplateFactory()
|
|
||||||
).data
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/templates/{template.id!s}/",
|
|
||||||
new_template_values,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": "You do not have permission to perform this action."
|
|
||||||
}
|
|
||||||
|
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
assert template_values == old_template_values
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
|
||||||
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
|
|
||||||
"""
|
|
||||||
Users who are readers of a template should not be allowed to update it.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
template = factories.TemplateFactory()
|
|
||||||
if via == USER:
|
|
||||||
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
|
|
||||||
elif via == TEAM:
|
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
|
||||||
factories.TeamTemplateAccessFactory(
|
|
||||||
template=template, team="lasuite", role="reader"
|
|
||||||
)
|
|
||||||
|
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
|
||||||
instance=factories.TemplateFactory()
|
|
||||||
).data
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/templates/{template.id!s}/",
|
|
||||||
new_template_values,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": "You do not have permission to perform this action."
|
|
||||||
}
|
|
||||||
|
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
assert template_values == old_template_values
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
|
||||||
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
|
|
||||||
via, role, mock_user_teams
|
|
||||||
):
|
|
||||||
"""Administrator or owner of a template should be allowed to update it."""
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
|
||||||
instance=factories.TemplateFactory()
|
|
||||||
).data
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/templates/{template.id!s}/",
|
|
||||||
new_template_values,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
for key, value in template_values.items():
|
|
||||||
if key in ["id", "accesses"]:
|
|
||||||
assert value == old_template_values[key]
|
|
||||||
else:
|
|
||||||
assert value == new_template_values[key]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
|
||||||
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
|
|
||||||
"""Administrators of a template should be allowed to update it."""
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
new_template_values = serializers.TemplateSerializer(
|
||||||
instance=factories.TemplateFactory()
|
instance=factories.TemplateFactory()
|
||||||
@@ -176,55 +45,10 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
|
|||||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 405
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
for key, value in template_values.items():
|
|
||||||
if key in ["id", "accesses"]:
|
|
||||||
assert value == old_template_values[key]
|
|
||||||
else:
|
|
||||||
assert value == new_template_values[key]
|
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
@pytest.mark.parametrize("via", VIA)
|
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||||
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
|
|
||||||
"""
|
|
||||||
Being administrator or owner of a template should not grant authorization to update
|
|
||||||
another template.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
template = factories.TemplateFactory()
|
|
||||||
if via == USER:
|
|
||||||
factories.UserTemplateAccessFactory(
|
|
||||||
template=template, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
elif via == TEAM:
|
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
|
||||||
factories.TeamTemplateAccessFactory(
|
|
||||||
template=template,
|
|
||||||
team="lasuite",
|
|
||||||
role=random.choice(["administrator", "owner"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
is_public = random.choice([True, False])
|
|
||||||
template = factories.TemplateFactory(title="Old title", is_public=is_public)
|
|
||||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
|
|
||||||
new_template_values = serializers.TemplateSerializer(
|
|
||||||
instance=factories.TemplateFactory()
|
|
||||||
).data
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/templates/{template.id!s}/",
|
|
||||||
new_template_values,
|
|
||||||
format="json",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403 if is_public else 404
|
assert response.status_code == 405
|
||||||
|
|
||||||
template.refresh_from_db()
|
|
||||||
template_values = serializers.TemplateSerializer(instance=template).data
|
|
||||||
assert template_values == old_template_values
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Test config API endpoints in the Impress core app.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ def test_api_config(is_authenticated):
|
|||||||
response = client.get("/api/v1.0/config/")
|
response = client.get("/api/v1.0/config/")
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
|
"AI_FEATURE_ENABLED": False,
|
||||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
|
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
|
||||||
"CRISP_WEBSITE_ID": "123",
|
"CRISP_WEBSITE_ID": "123",
|
||||||
@@ -59,7 +61,7 @@ def test_api_config(is_authenticated):
|
|||||||
"MEDIA_BASE_URL": "http://testserver/",
|
"MEDIA_BASE_URL": "http://testserver/",
|
||||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||||
"SENTRY_DSN": "https://sentry.test/123",
|
"SENTRY_DSN": "https://sentry.test/123",
|
||||||
"AI_FEATURE_ENABLED": False,
|
"TRASHBIN_CUTOFF_DAYS": 30,
|
||||||
"theme_customization": {},
|
"theme_customization": {},
|
||||||
}
|
}
|
||||||
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
|
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
|
||||||
@@ -174,3 +176,20 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
|
|||||||
theme_customization = json.load(f)
|
theme_customization = json.load(f)
|
||||||
|
|
||||||
assert content["theme_customization"] == theme_customization
|
assert content["theme_customization"] == theme_customization
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_config_throttling(settings):
|
||||||
|
"""Test api config throttling."""
|
||||||
|
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"]
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = "2/minute"
|
||||||
|
client = APIClient()
|
||||||
|
for _i in range(2):
|
||||||
|
response = client.get("/api/v1.0/config/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
with patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||||
|
response = client.get("/api/v1.0/config/")
|
||||||
|
assert response.status_code == 429
|
||||||
|
mock_capture_message.assert_called_once_with(
|
||||||
|
"Rate limit exceeded for scope config", "warning"
|
||||||
|
)
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = current_rate
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ def test_api_users_list_query_short_queries():
|
|||||||
"""
|
"""
|
||||||
Queries shorter than 5 characters should return an empty result set.
|
Queries shorter than 5 characters should return an empty result set.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory(email="paul@example.com")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
@@ -194,18 +194,41 @@ def test_api_users_list_query_short_queries():
|
|||||||
factories.UserFactory(email="john.lennon@example.com")
|
factories.UserFactory(email="john.lennon@example.com")
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=jo")
|
response = client.get("/api/v1.0/users/?q=jo")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 400
|
||||||
assert response.json() == []
|
assert response.json() == {
|
||||||
|
"q": ["Ensure this value has at least 5 characters (it has 2)."]
|
||||||
|
}
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=john")
|
response = client.get("/api/v1.0/users/?q=john")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 400
|
||||||
assert response.json() == []
|
assert response.json() == {
|
||||||
|
"q": ["Ensure this value has at least 5 characters (it has 4)."]
|
||||||
|
}
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=john.")
|
response = client.get("/api/v1.0/users/?q=john.")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert len(response.json()) == 2
|
assert len(response.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_list_query_long_queries():
|
||||||
|
"""
|
||||||
|
Queries longer than 255 characters should return an empty result set.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(email="paul@example.com")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.UserFactory(email="john.doe@example.com")
|
||||||
|
factories.UserFactory(email="john.lennon@example.com")
|
||||||
|
|
||||||
|
query = "a" * 244
|
||||||
|
response = client.get(f"/api/v1.0/users/?q={query}@example.com")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"q": ["Ensure this value has at most 254 characters (it has 256)."]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_query_inactive():
|
def test_api_users_list_query_inactive():
|
||||||
"""Inactive users should not be listed."""
|
"""Inactive users should not be listed."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -255,6 +278,35 @@ def test_api_users_retrieve_me_authenticated():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_retrieve_me_authenticated_empty_name():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to retrieve their own user via the "/users/me" path.
|
||||||
|
when no name is provided, the full name and short name should be the email without the domain.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(
|
||||||
|
email="test_foo@test.com",
|
||||||
|
full_name=None,
|
||||||
|
short_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.UserFactory.create_batch(2)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/users/me/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": "test_foo@test.com",
|
||||||
|
"full_name": "test_foo",
|
||||||
|
"language": user.language,
|
||||||
|
"short_name": "test_foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_retrieve_anonymous():
|
def test_api_users_retrieve_anonymous():
|
||||||
"""Anonymous users should not be allowed to retrieve a user."""
|
"""Anonymous users should not be allowed to retrieve a user."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
283
src/backend/core/tests/test_models_comment.py
Normal file
283
src/backend/core/tests/test_models_comment.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"""Test the comment model."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role,can_comment",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, True),
|
||||||
|
(LinkRoleChoices.EDITOR, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
|
||||||
|
"""Anonymous users cannot comment on a document."""
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=role, link_reach=LinkReachChoices.PUBLIC
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document)
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"reactions": False,
|
||||||
|
"retrieve": can_comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
|
||||||
|
"""Anonymous users cannot comment on a restricted document."""
|
||||||
|
document = factories.DocumentFactory(link_reach=link_reach)
|
||||||
|
comment = factories.CommentFactory(thread__document=document)
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"reactions": False,
|
||||||
|
"retrieve": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach,can_comment",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
|
||||||
|
"""Readers cannot comment on a document."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"reactions": can_comment,
|
||||||
|
"retrieve": can_comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach,can_comment",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_reader_own_comment(
|
||||||
|
link_role, link_reach, can_comment
|
||||||
|
):
|
||||||
|
"""User with reader role on a document has all accesses to its own comment."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(
|
||||||
|
thread__document=document, user=user if can_comment else None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": can_comment,
|
||||||
|
"update": can_comment,
|
||||||
|
"partial_update": can_comment,
|
||||||
|
"reactions": can_comment,
|
||||||
|
"retrieve": can_comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_commenter(link_role, link_reach):
|
||||||
|
"""Commenters can comment on a document."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role,
|
||||||
|
link_reach=link_reach,
|
||||||
|
users=[(user, RoleChoices.COMMENTER)],
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach):
|
||||||
|
"""Commenters have all accesses to its own comment."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role,
|
||||||
|
link_reach=link_reach,
|
||||||
|
users=[(user, RoleChoices.COMMENTER)],
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document, user=user)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_editor(link_role, link_reach):
|
||||||
|
"""Editors can comment on a document."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"link_role,link_reach",
|
||||||
|
[
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
|
||||||
|
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
|
||||||
|
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
|
||||||
|
"""Editors have all accesses to its own comment."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
|
||||||
|
)
|
||||||
|
comment = factories.CommentFactory(thread__document=document, user=user)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_get_abilities_user_admin():
|
||||||
|
"""Admins have all accesses to a comment."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
|
||||||
|
comment = factories.CommentFactory(
|
||||||
|
thread__document=document, user=random.choice([user, None])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_get_abilities_user_owner():
|
||||||
|
"""Owners have all accesses to a comment."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
comment = factories.CommentFactory(
|
||||||
|
thread__document=document, user=random.choice([user, None])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comment.get_abilities(user) == {
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"reactions": True,
|
||||||
|
"retrieve": True,
|
||||||
|
}
|
||||||
@@ -123,16 +123,22 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_for_owner_of_self_last():
|
def test_models_document_access_get_abilities_for_owner_of_self_last_on_root(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Check abilities of self access for the owner of a document when there is only one owner left.
|
Check abilities of self access for the owner of a root document when there
|
||||||
|
is only one owner left.
|
||||||
"""
|
"""
|
||||||
access = factories.UserDocumentAccessFactory(role="owner")
|
access = factories.UserDocumentAccessFactory(role="owner")
|
||||||
abilities = access.get_abilities(access.user)
|
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
|
||||||
assert abilities == {
|
assert abilities == {
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
@@ -142,6 +148,28 @@ def test_models_document_access_get_abilities_for_owner_of_self_last():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check abilities of self access for the owner of a child document when there
|
||||||
|
is only one owner left.
|
||||||
|
"""
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner")
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_for_owner_of_owner():
|
def test_models_document_access_get_abilities_for_owner_of_owner():
|
||||||
"""Check abilities of owner access for the owner of a document."""
|
"""Check abilities of owner access for the owner of a document."""
|
||||||
access = factories.UserDocumentAccessFactory(role="owner")
|
access = factories.UserDocumentAccessFactory(role="owner")
|
||||||
@@ -155,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "editor", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -189,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "administrator", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "administrator", "editor"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["editor", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "reader"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -277,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor"],
|
"set_role_to": ["reader", "commenter", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -400,12 +428,12 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
|
|||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
|
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
|
||||||
"""No query is done if the role is preset, e.g., with a query annotation."""
|
"""No query is done if user roles are preset on the document, e.g., with a query annotation."""
|
||||||
access = factories.UserDocumentAccessFactory(role="reader")
|
access = factories.UserDocumentAccessFactory(role="reader")
|
||||||
user = factories.UserDocumentAccessFactory(
|
user = factories.UserDocumentAccessFactory(
|
||||||
document=access.document, role="reader"
|
document=access.document, role="reader"
|
||||||
).user
|
).user
|
||||||
access.user_roles = ["reader"]
|
access.set_user_roles_tuple(None, "reader")
|
||||||
|
|
||||||
with django_assert_num_queries(0):
|
with django_assert_num_queries(0):
|
||||||
abilities = access.get_abilities(user)
|
abilities = access.get_abilities(user)
|
||||||
|
|||||||
@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
|
|||||||
[
|
[
|
||||||
(True, "restricted", "reader"),
|
(True, "restricted", "reader"),
|
||||||
(True, "restricted", "editor"),
|
(True, "restricted", "editor"),
|
||||||
|
(True, "restricted", "commenter"),
|
||||||
(False, "restricted", "reader"),
|
(False, "restricted", "reader"),
|
||||||
(False, "restricted", "editor"),
|
(False, "restricted", "editor"),
|
||||||
|
(False, "restricted", "commenter"),
|
||||||
(False, "authenticated", "reader"),
|
(False, "authenticated", "reader"),
|
||||||
(False, "authenticated", "editor"),
|
(False, "authenticated", "editor"),
|
||||||
|
(False, "authenticated", "commenter"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_models_documents_get_abilities_forbidden(
|
def test_models_documents_get_abilities_forbidden(
|
||||||
@@ -155,23 +158,27 @@ def test_models_documents_get_abilities_forbidden(
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": False,
|
"children_list": False,
|
||||||
"collaboration_auth": False,
|
"collaboration_auth": False,
|
||||||
"descendants": False,
|
"descendants": False,
|
||||||
"cors_proxy": False,
|
"cors_proxy": False,
|
||||||
|
"content": False,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": False,
|
"duplicate": False,
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
|
"comment": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
|
"mask": False,
|
||||||
"media_auth": False,
|
"media_auth": False,
|
||||||
"media_check": False,
|
"media_check": False,
|
||||||
"move": False,
|
"move": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"restore": False,
|
"restore": False,
|
||||||
@@ -216,21 +223,25 @@ def test_models_documents_get_abilities_reader(
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": False,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": is_authenticated,
|
||||||
"favorite": is_authenticated,
|
"favorite": is_authenticated,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": is_authenticated,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -252,7 +263,77 @@ def test_models_documents_get_abilities_reader(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"is_authenticated,reach",
|
||||||
|
[
|
||||||
|
(True, "public"),
|
||||||
|
(False, "public"),
|
||||||
|
(True, "authenticated"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_models_documents_get_abilities_commenter(
|
||||||
|
is_authenticated, reach, django_assert_num_queries
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check abilities returned for a document giving commenter role to link holders
|
||||||
|
i.e anonymous users or authenticated users who have no specific role on the document.
|
||||||
|
"""
|
||||||
|
document = factories.DocumentFactory(link_reach=reach, link_role="commenter")
|
||||||
|
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||||
|
expected_abilities = {
|
||||||
|
"accesses_manage": False,
|
||||||
|
"accesses_view": False,
|
||||||
|
"ai_transform": False,
|
||||||
|
"ai_translate": False,
|
||||||
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
|
"children_create": False,
|
||||||
|
"children_list": True,
|
||||||
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
|
"content": True,
|
||||||
|
"descendants": True,
|
||||||
|
"cors_proxy": True,
|
||||||
|
"destroy": False,
|
||||||
|
"duplicate": is_authenticated,
|
||||||
|
"favorite": is_authenticated,
|
||||||
|
"invite_owner": False,
|
||||||
|
"link_configuration": False,
|
||||||
|
"link_select_options": {
|
||||||
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
"restricted": None,
|
||||||
|
},
|
||||||
|
"mask": is_authenticated,
|
||||||
|
"media_auth": True,
|
||||||
|
"media_check": True,
|
||||||
|
"move": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"restore": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"tree": True,
|
||||||
|
"update": False,
|
||||||
|
"versions_destroy": False,
|
||||||
|
"versions_list": False,
|
||||||
|
"versions_retrieve": False,
|
||||||
|
}
|
||||||
|
nb_queries = 1 if is_authenticated else 0
|
||||||
|
with django_assert_num_queries(nb_queries):
|
||||||
|
assert document.get_abilities(user) == expected_abilities
|
||||||
|
|
||||||
|
document.soft_delete()
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert all(
|
||||||
|
value is False
|
||||||
|
for key, value in document.get_abilities(user).items()
|
||||||
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,21 +360,25 @@ def test_models_documents_get_abilities_editor(
|
|||||||
"ai_transform": is_authenticated,
|
"ai_transform": is_authenticated,
|
||||||
"ai_translate": is_authenticated,
|
"ai_translate": is_authenticated,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": is_authenticated,
|
"children_create": is_authenticated,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": is_authenticated,
|
||||||
"favorite": is_authenticated,
|
"favorite": is_authenticated,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": is_authenticated,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -314,7 +399,7 @@ def test_models_documents_get_abilities_editor(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,21 +416,25 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": True,
|
"destroy": True,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": True,
|
"invite_owner": True,
|
||||||
"link_configuration": True,
|
"link_configuration": True,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": True,
|
"move": True,
|
||||||
@@ -363,8 +452,43 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
|||||||
|
|
||||||
document.soft_delete()
|
document.soft_delete()
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
expected_abilities["move"] = False
|
assert document.get_abilities(user) == {
|
||||||
assert document.get_abilities(user) == expected_abilities
|
"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,
|
||||||
|
"comment": False,
|
||||||
|
"descendants": False,
|
||||||
|
"cors_proxy": False,
|
||||||
|
"content": False,
|
||||||
|
"destroy": False,
|
||||||
|
"duplicate": False,
|
||||||
|
"favorite": False,
|
||||||
|
"invite_owner": False,
|
||||||
|
"link_configuration": False,
|
||||||
|
"link_select_options": {
|
||||||
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
"restricted": None,
|
||||||
|
},
|
||||||
|
"mask": False,
|
||||||
|
"media_auth": False,
|
||||||
|
"media_check": False,
|
||||||
|
"move": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"restore": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"tree": True,
|
||||||
|
"update": False,
|
||||||
|
"versions_destroy": False,
|
||||||
|
"versions_list": False,
|
||||||
|
"versions_retrieve": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
@@ -380,21 +504,25 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": True,
|
"link_configuration": True,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": True,
|
"move": True,
|
||||||
@@ -415,7 +543,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -432,21 +560,25 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -467,7 +599,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -491,21 +623,26 @@ def test_models_documents_get_abilities_reader_user(
|
|||||||
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
||||||
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
||||||
"attachment_upload": access_from_link,
|
"attachment_upload": access_from_link,
|
||||||
|
"can_edit": access_from_link,
|
||||||
"children_create": access_from_link,
|
"children_create": access_from_link,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": document.link_reach != "restricted"
|
||||||
|
and document.link_role in ["commenter", "editor"],
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -528,7 +665,72 @@ def test_models_documents_get_abilities_reader_user(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||||
|
def test_models_documents_get_abilities_commenter_user(
|
||||||
|
ai_access_setting, django_assert_num_queries
|
||||||
|
):
|
||||||
|
"""Check abilities returned for the commenter of a document."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(users=[(user, "commenter")])
|
||||||
|
|
||||||
|
access_from_link = (
|
||||||
|
document.link_reach != "restricted" and document.link_role == "editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_abilities = {
|
||||||
|
"accesses_manage": False,
|
||||||
|
"accesses_view": True,
|
||||||
|
# If you get your editor rights from the link role and not your access role
|
||||||
|
# You should not access AI if it's restricted to users with specific access
|
||||||
|
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
||||||
|
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
||||||
|
"attachment_upload": access_from_link,
|
||||||
|
"can_edit": access_from_link,
|
||||||
|
"children_create": access_from_link,
|
||||||
|
"children_list": True,
|
||||||
|
"collaboration_auth": True,
|
||||||
|
"comment": True,
|
||||||
|
"content": True,
|
||||||
|
"descendants": True,
|
||||||
|
"cors_proxy": True,
|
||||||
|
"destroy": False,
|
||||||
|
"duplicate": True,
|
||||||
|
"favorite": True,
|
||||||
|
"invite_owner": False,
|
||||||
|
"link_configuration": False,
|
||||||
|
"link_select_options": {
|
||||||
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
"restricted": None,
|
||||||
|
},
|
||||||
|
"mask": True,
|
||||||
|
"media_auth": True,
|
||||||
|
"media_check": True,
|
||||||
|
"move": False,
|
||||||
|
"partial_update": access_from_link,
|
||||||
|
"restore": False,
|
||||||
|
"retrieve": True,
|
||||||
|
"tree": True,
|
||||||
|
"update": access_from_link,
|
||||||
|
"versions_destroy": False,
|
||||||
|
"versions_list": True,
|
||||||
|
"versions_retrieve": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
assert document.get_abilities(user) == expected_abilities
|
||||||
|
|
||||||
|
document.soft_delete()
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert all(
|
||||||
|
value is False
|
||||||
|
for key, value in document.get_abilities(user).items()
|
||||||
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -548,21 +750,25 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
|
"comment": False,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -577,6 +783,86 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"is_authenticated, is_creator,role,link_reach,link_role,can_destroy",
|
||||||
|
[
|
||||||
|
(True, False, "owner", "restricted", "editor", True),
|
||||||
|
(True, True, "owner", "restricted", "editor", True),
|
||||||
|
(True, False, "owner", "restricted", "reader", True),
|
||||||
|
(True, True, "owner", "restricted", "reader", True),
|
||||||
|
(True, False, "owner", "authenticated", "editor", True),
|
||||||
|
(True, True, "owner", "authenticated", "editor", True),
|
||||||
|
(True, False, "owner", "authenticated", "reader", True),
|
||||||
|
(True, True, "owner", "authenticated", "reader", True),
|
||||||
|
(True, False, "owner", "public", "editor", True),
|
||||||
|
(True, True, "owner", "public", "editor", True),
|
||||||
|
(True, False, "owner", "public", "reader", True),
|
||||||
|
(True, True, "owner", "public", "reader", True),
|
||||||
|
(True, False, "administrator", "restricted", "editor", True),
|
||||||
|
(True, True, "administrator", "restricted", "editor", True),
|
||||||
|
(True, False, "administrator", "restricted", "reader", True),
|
||||||
|
(True, True, "administrator", "restricted", "reader", True),
|
||||||
|
(True, False, "administrator", "authenticated", "editor", True),
|
||||||
|
(True, True, "administrator", "authenticated", "editor", True),
|
||||||
|
(True, False, "administrator", "authenticated", "reader", True),
|
||||||
|
(True, True, "administrator", "authenticated", "reader", True),
|
||||||
|
(True, False, "administrator", "public", "editor", True),
|
||||||
|
(True, True, "administrator", "public", "editor", True),
|
||||||
|
(True, False, "administrator", "public", "reader", True),
|
||||||
|
(True, True, "administrator", "public", "reader", True),
|
||||||
|
(True, False, "editor", "restricted", "editor", False),
|
||||||
|
(True, True, "editor", "restricted", "editor", True),
|
||||||
|
(True, False, "editor", "restricted", "reader", False),
|
||||||
|
(True, True, "editor", "restricted", "reader", True),
|
||||||
|
(True, False, "editor", "authenticated", "editor", False),
|
||||||
|
(True, True, "editor", "authenticated", "editor", True),
|
||||||
|
(True, False, "editor", "authenticated", "reader", False),
|
||||||
|
(True, True, "editor", "authenticated", "reader", True),
|
||||||
|
(True, False, "editor", "public", "editor", False),
|
||||||
|
(True, True, "editor", "public", "editor", True),
|
||||||
|
(True, False, "editor", "public", "reader", False),
|
||||||
|
(True, True, "editor", "public", "reader", True),
|
||||||
|
(True, False, "reader", "restricted", "editor", False),
|
||||||
|
(True, False, "reader", "restricted", "reader", False),
|
||||||
|
(True, False, "reader", "authenticated", "editor", False),
|
||||||
|
(True, True, "reader", "authenticated", "editor", True),
|
||||||
|
(True, False, "reader", "authenticated", "reader", False),
|
||||||
|
(True, False, "reader", "public", "editor", False),
|
||||||
|
(True, True, "reader", "public", "editor", True),
|
||||||
|
(True, False, "reader", "public", "reader", False),
|
||||||
|
(False, False, None, "restricted", "editor", False),
|
||||||
|
(False, False, None, "restricted", "reader", False),
|
||||||
|
(False, False, None, "authenticated", "editor", False),
|
||||||
|
(False, False, None, "authenticated", "reader", False),
|
||||||
|
(False, False, None, "public", "editor", False),
|
||||||
|
(False, False, None, "public", "reader", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
|
def test_models_documents_get_abilities_children_destroy( # noqa: PLR0913
|
||||||
|
is_authenticated,
|
||||||
|
is_creator,
|
||||||
|
role,
|
||||||
|
link_reach,
|
||||||
|
link_role,
|
||||||
|
can_destroy,
|
||||||
|
):
|
||||||
|
"""For a sub document, if a user can create children, he can destroy it."""
|
||||||
|
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||||
|
parent = factories.DocumentFactory(link_reach=link_reach, link_role=link_role)
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
link_reach=link_reach,
|
||||||
|
link_role=link_role,
|
||||||
|
parent=parent,
|
||||||
|
creator=user if is_creator else None,
|
||||||
|
)
|
||||||
|
if is_authenticated:
|
||||||
|
factories.UserDocumentAccessFactory(document=parent, user=user, role=role)
|
||||||
|
|
||||||
|
abilities = document.get_abilities(user)
|
||||||
|
assert abilities["destroy"] is can_destroy
|
||||||
|
|
||||||
|
|
||||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"is_authenticated,reach",
|
"is_authenticated,reach",
|
||||||
@@ -1176,184 +1462,158 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"ancestors_links, select_options",
|
"reach, role, select_options",
|
||||||
[
|
[
|
||||||
# One ancestor
|
|
||||||
(
|
(
|
||||||
[{"link_reach": "public", "link_role": "reader"}],
|
"public",
|
||||||
|
"reader",
|
||||||
{
|
{
|
||||||
"restricted": ["editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"public",
|
||||||
|
"commenter",
|
||||||
|
{
|
||||||
|
"public": ["commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("public", "editor", {"public": ["editor"]}),
|
||||||
|
(
|
||||||
|
"authenticated",
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authenticated",
|
||||||
|
"commenter",
|
||||||
|
{
|
||||||
|
"authenticated": ["commenter", "editor"],
|
||||||
|
"public": ["commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authenticated",
|
||||||
|
"editor",
|
||||||
|
{"authenticated": ["editor"], "public": ["editor"]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"restricted",
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
"restricted": None,
|
||||||
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
|
"public": ["reader", "commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"restricted",
|
||||||
|
"commenter",
|
||||||
|
{
|
||||||
|
"restricted": None,
|
||||||
|
"authenticated": ["commenter", "editor"],
|
||||||
|
"public": ["commenter", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"restricted",
|
||||||
|
"editor",
|
||||||
|
{
|
||||||
|
"restricted": None,
|
||||||
"authenticated": ["editor"],
|
"authenticated": ["editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["editor"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
|
# Edge cases
|
||||||
(
|
(
|
||||||
[{"link_reach": "authenticated", "link_role": "reader"}],
|
"public",
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"restricted": ["editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
[{"link_reach": "authenticated", "link_role": "editor"}],
|
None,
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
"reader",
|
||||||
),
|
|
||||||
(
|
|
||||||
[{"link_reach": "restricted", "link_role": "reader"}],
|
|
||||||
{
|
{
|
||||||
"restricted": ["reader", "editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
[{"link_reach": "restricted", "link_role": "editor"}],
|
None,
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"restricted": ["editor"],
|
"public": ["reader", "commenter", "editor"],
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "commenter", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with different roles
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"public": ["editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "restricted", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with different reaches
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with mixed reaches and roles
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"public": ["editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "editor"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
# No ancestors (edge case)
|
|
||||||
(
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"restricted": ["reader", "editor"],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
def test_models_documents_get_select_options(reach, role, select_options):
|
||||||
"""Validate that the "get_select_options" method operates as expected."""
|
"""Validate that the "get_select_options" method operates as expected."""
|
||||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
assert models.LinkReachChoices.get_select_options(reach, role) == select_options
|
||||||
|
|
||||||
|
|
||||||
def test_models_documents_compute_ancestors_links_no_highest_readable():
|
def test_models_documents_compute_ancestors_links_paths_mapping_single(
|
||||||
"""Test the compute_ancestors_links method."""
|
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
|
||||||
assert document.compute_ancestors_links(user=AnonymousUser()) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_documents_compute_ancestors_links_highest_readable(
|
|
||||||
django_assert_num_queries,
|
django_assert_num_queries,
|
||||||
):
|
):
|
||||||
"""Test the compute_ancestors_links method."""
|
"""Test the compute_ancestors_links_paths_mapping method on a single document."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
assert document.compute_ancestors_links_paths_mapping() == {
|
||||||
|
document.path: [{"link_reach": "public", "link_role": document.link_role}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""Test the compute_ancestors_links_paths_mapping method on a tree of documents."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
other_user = factories.UserFactory()
|
other_user = factories.UserFactory()
|
||||||
root = factories.DocumentFactory(
|
|
||||||
link_reach="restricted", link_role="reader", users=[user]
|
|
||||||
)
|
|
||||||
|
|
||||||
factories.DocumentFactory(
|
root = factories.DocumentFactory(link_reach="restricted", users=[user])
|
||||||
parent=root, link_reach="public", link_role="reader", users=[user]
|
document = factories.DocumentFactory(
|
||||||
)
|
|
||||||
child2 = factories.DocumentFactory(
|
|
||||||
parent=root,
|
parent=root,
|
||||||
link_reach="authenticated",
|
link_reach="authenticated",
|
||||||
link_role="editor",
|
link_role="editor",
|
||||||
users=[user, other_user],
|
users=[user, other_user],
|
||||||
)
|
)
|
||||||
child3 = factories.DocumentFactory(
|
sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user])
|
||||||
parent=child2,
|
child = factories.DocumentFactory(
|
||||||
|
parent=document,
|
||||||
link_reach="authenticated",
|
link_reach="authenticated",
|
||||||
link_role="reader",
|
link_role="reader",
|
||||||
users=[user, other_user],
|
users=[user, other_user],
|
||||||
)
|
)
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
# Child
|
||||||
assert child3.compute_ancestors_links(user=user) == [
|
with django_assert_num_queries(1):
|
||||||
{"link_reach": root.link_reach, "link_role": root.link_role},
|
assert child.compute_ancestors_links_paths_mapping() == {
|
||||||
{"link_reach": child2.link_reach, "link_role": child2.link_role},
|
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
|
||||||
]
|
document.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": document.link_reach, "link_role": document.link_role},
|
||||||
|
],
|
||||||
|
child.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": document.link_reach, "link_role": document.link_role},
|
||||||
|
{"link_reach": child.link_reach, "link_role": child.link_role},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
# Sibling
|
||||||
assert child3.compute_ancestors_links(user=other_user) == [
|
with django_assert_num_queries(1):
|
||||||
{"link_reach": child2.link_reach, "link_role": child2.link_role},
|
assert sibling.compute_ancestors_links_paths_mapping() == {
|
||||||
]
|
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
|
||||||
|
sibling.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import factories
|
from core import factories, models
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -44,3 +44,55 @@ def test_models_users_send_mail_main_missing():
|
|||||||
user.email_user("my subject", "my message")
|
user.email_user("my subject", "my message")
|
||||||
|
|
||||||
assert str(excinfo.value) == "User has no email address."
|
assert str(excinfo.value) == "User has no email address."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sub,is_valid",
|
||||||
|
[
|
||||||
|
("valid_sub.@+-:=/", True),
|
||||||
|
("invalid süb", False),
|
||||||
|
(12345, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_models_users_sub_validator(sub, is_valid):
|
||||||
|
"""The "sub" field should be validated."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
user.sub = sub
|
||||||
|
if is_valid:
|
||||||
|
user.full_clean()
|
||||||
|
else:
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match=("Enter a valid sub. This value should be ASCII only."),
|
||||||
|
):
|
||||||
|
user.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modes_users_convert_valid_invitations():
|
||||||
|
"""
|
||||||
|
The "convert_valid_invitations" method should convert valid invitations to document accesses.
|
||||||
|
"""
|
||||||
|
email = "test@example.com"
|
||||||
|
document = factories.DocumentFactory()
|
||||||
|
other_document = factories.DocumentFactory()
|
||||||
|
invitation_document = factories.InvitationFactory(email=email, document=document)
|
||||||
|
invitation_other_document = factories.InvitationFactory(
|
||||||
|
email="Test@example.coM", document=other_document
|
||||||
|
)
|
||||||
|
other_email_invitation = factories.InvitationFactory(
|
||||||
|
email="pre_test@example.com", document=document
|
||||||
|
)
|
||||||
|
|
||||||
|
assert document.accesses.count() == 0
|
||||||
|
assert other_document.accesses.count() == 0
|
||||||
|
|
||||||
|
user = factories.UserFactory(email=email)
|
||||||
|
|
||||||
|
assert document.accesses.filter(user=user).count() == 1
|
||||||
|
assert other_document.accesses.filter(user=user).count() == 1
|
||||||
|
|
||||||
|
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
|
||||||
|
assert not models.Invitation.objects.filter(
|
||||||
|
id=invitation_other_document.id
|
||||||
|
).exists()
|
||||||
|
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user