mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
558 Commits
documentat
...
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 | ||
|
|
6c3850b22b | ||
|
|
31e8ed3a00 | ||
|
|
7e63e9e460 | ||
|
|
388f71d9d0 | ||
|
|
2360a832af | ||
|
|
411d52c73b | ||
|
|
394f91387d | ||
|
|
878de08b1e | ||
|
|
d33286019c | ||
|
|
c2e46fa9e2 | ||
|
|
2e1b112133 | ||
|
|
8f7ac12ea1 | ||
|
|
dfdfe83db5 | ||
|
|
4ae757ce93 | ||
|
|
6964686f7c | ||
|
|
45bbffdf9f | ||
|
|
95a55e7805 | ||
|
|
e9ac36e811 | ||
|
|
d8294ee11d | ||
|
|
00009ecc16 | ||
|
|
9b0676ec15 | ||
|
|
9f222bbaa3 | ||
|
|
f0b253f0ff | ||
|
|
1e76e6e04c | ||
|
|
a71453206b | ||
|
|
71cd016d4d | ||
|
|
2a7ffff96d | ||
|
|
ff8275fb4e | ||
|
|
c3f81c2b62 | ||
|
|
c7261cf507 | ||
|
|
e504f43611 | ||
|
|
3ad6d0ea12 | ||
|
|
9e8a7b3502 | ||
|
|
05db9c8e51 | ||
|
|
7ed33019c2 | ||
|
|
a99c813421 | ||
|
|
a83902a0d4 | ||
|
|
080f855083 | ||
|
|
90d94f6b7a | ||
|
|
f97ab51c8e | ||
|
|
ba4f90a607 | ||
|
|
6c16e081de | ||
|
|
56a945983e | ||
|
|
4fbbead405 | ||
|
|
9a212400a0 | ||
|
|
f07fcd4c0d | ||
|
|
4fc49d5cb2 | ||
|
|
0fd16b4371 | ||
|
|
fbb2799050 | ||
|
|
afbb4b29dc | ||
|
|
db63ebd0c8 | ||
|
|
c5f018e03e | ||
|
|
1c93fbc007 | ||
|
|
d811e3c2fc | ||
|
|
fe5fda5d73 | ||
|
|
bf66265125 | ||
|
|
ce329142dc | ||
|
|
f8cff43dac | ||
|
|
f5b2c27bd8 | ||
|
|
62433ef7f1 | ||
|
|
bc0824d110 | ||
|
|
fa653c6776 | ||
|
|
d12f942d29 | ||
|
|
62f85e7d24 | ||
|
|
65cc088a17 | ||
|
|
94e99784f3 | ||
|
|
fa83955a77 | ||
|
|
5962f7aae1 | ||
|
|
dc06315566 | ||
|
|
f4ad26a8fa | ||
|
|
d952815932 | ||
|
|
cde64ed80a | ||
|
|
cfd88d0469 | ||
|
|
5e45fec296 | ||
|
|
393e7a06e2 | ||
|
|
f1af87baf8 | ||
|
|
f851ef2d85 | ||
|
|
252ab6a586 | ||
|
|
cf2a02c8de | ||
|
|
d87a2ed4eb | ||
|
|
c9d053d1c0 | ||
|
|
b5f0f06ea3 | ||
|
|
36b0ff9f63 | ||
|
|
7a383957a7 | ||
|
|
b5630359ee | ||
|
|
310154815b | ||
|
|
2733785016 | ||
|
|
99ba414d88 | ||
|
|
41631b5b70 | ||
|
|
6ca654bf1a | ||
|
|
074585337b | ||
|
|
f1b398e1ae | ||
|
|
d1f73f18cd | ||
|
|
3f2d84bf62 | ||
|
|
7b9c362d38 | ||
|
|
bf999979d2 | ||
|
|
09d3ff3754 | ||
|
|
6e5d005dee | ||
|
|
6377c8fcca | ||
|
|
3c8cacc048 | ||
|
|
598fb4fa27 | ||
|
|
51618ad081 | ||
|
|
8109d5ba08 | ||
|
|
e4d0179bbe | ||
|
|
9d3dfb6de7 | ||
|
|
0da042f887 | ||
|
|
6cd0cd0689 | ||
|
|
10b088599c | ||
|
|
62d1bc6473 | ||
|
|
fc1d33268c | ||
|
|
95833fa5ec | ||
|
|
dd6e0b5072 | ||
|
|
95d3a8cd18 | ||
|
|
4f126ab824 | ||
|
|
fb90c13dad | ||
|
|
4118d79525 | ||
|
|
5848f43cb4 | ||
|
|
4b0fd223c8 | ||
|
|
31d0733851 | ||
|
|
16e20e984c | ||
|
|
76c28760dc | ||
|
|
d856abb5d8 | ||
|
|
25abd964de | ||
|
|
a070e1dd87 | ||
|
|
37d9ae8cca | ||
|
|
29ea6b8ef7 | ||
|
|
a692fa6f39 | ||
|
|
4d541c5d52 | ||
|
|
e5f029ad1d | ||
|
|
bd79f84e07 | ||
|
|
a070f56339 | ||
|
|
02478acb3f | ||
|
|
23aa497db0 | ||
|
|
d48436bffb | ||
|
|
41e4c45934 | ||
|
|
6be87ed477 | ||
|
|
c96182b3e3 | ||
|
|
e79d1d618a | ||
|
|
2691cdd4a2 | ||
|
|
05a1390bdc | ||
|
|
dfe8ae14fe | ||
|
|
74165f6890 | ||
|
|
349cbf8eb3 | ||
|
|
12ef1a2450 |
@@ -34,3 +34,4 @@ db.sqlite3
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
|
|||||||
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Set the default behavior for all files
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary files (should not be modified)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.mov binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.flv binary
|
||||||
|
*.fla binary
|
||||||
|
*.swf binary
|
||||||
|
*.gz binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.ttf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.eot binary
|
||||||
|
*.pdf binary
|
||||||
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!
|
|
||||||
-->
|
|
||||||
10
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
10
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: If something is not working as expected 🤔.
|
about: If something is not working as expected 🤔.
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -18,8 +22,8 @@ A clear and concise description of what you expected to happen (or code).
|
|||||||
3. And then the bug happens!
|
3. And then the bug happens!
|
||||||
|
|
||||||
**Environment**
|
**Environment**
|
||||||
- Impress version:
|
- Docs version:
|
||||||
- Platform:
|
- Instance url:
|
||||||
|
|
||||||
**Possible Solution**
|
**Possible Solution**
|
||||||
<!--- Only if you have suggestions on a fix for the bug -->
|
<!--- Only if you have suggestions on a fix for the bug -->
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: I have a suggestion (and may want to build it 💪)!
|
about: I have a suggestion (and may want to build it 💪)!
|
||||||
|
labels: ["feature", "triage"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Request
|
## Feature Request
|
||||||
@@ -16,8 +16,8 @@ A clear and concise description of what you want to happen. Add any considered d
|
|||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
|
||||||
Maybe a screenshot or design?
|
Maybe add a screenshot or design?
|
||||||
|
|
||||||
**Do you want to work on it through a Pull Request?**
|
**Do you want to work on it through a Pull Request?**
|
||||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||||
|
|||||||
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
@@ -1,17 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: 🤗 Support Question
|
name: 🤗 Support Question
|
||||||
about: If you have a question 💬, or something was not clear from the docs!
|
about: If you have a question 💬, or something was not clear from the docs!
|
||||||
|
labels: ["support", "triage"]
|
||||||
---
|
---
|
||||||
|
## Support request
|
||||||
|
**Checks before filing**
|
||||||
|
Please make sure you have read our [main Readme](https://github.com/suitenumerique/docs).
|
||||||
|
|
||||||
<!-- ^ Click "Preview" for a nicer view! ^
|
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/docs/issues?q=is%3Aissue%20state%3Aopen%20label%3Asupport).
|
||||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
|
|
||||||
|
|
||||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
|
|
||||||
|
|
||||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||||
|
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,22 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Description...
|
Describe the purpose of this pull request.
|
||||||
|
|
||||||
|
|
||||||
## Proposal
|
## Proposal
|
||||||
|
|
||||||
Description...
|
- [ ] item 1...
|
||||||
|
- [ ] item 2...
|
||||||
|
|
||||||
- [] item 1...
|
## External contributions
|
||||||
- [] item 2...
|
|
||||||
|
Thank you for your contribution! 🎉
|
||||||
|
|
||||||
|
Please ensure the following items are checked before submitting your pull request:
|
||||||
|
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
|
||||||
|
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
|
||||||
|
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
|
||||||
|
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
|
||||||
|
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
|
||||||
|
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
|
||||||
|
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
|
||||||
2
.github/workflows/crowdin_download.yml
vendored
2
.github/workflows/crowdin_download.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
|
|||||||
7
.github/workflows/crowdin_upload.yml
vendored
7
.github/workflows/crowdin_upload.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
with-build_mails: true
|
with-build_mails: true
|
||||||
|
|
||||||
@@ -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.12.6"
|
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
|
||||||
|
|||||||
2
.github/workflows/dependencies.yml
vendored
2
.github/workflows/dependencies.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: '20.x'
|
default: '22.x'
|
||||||
type: string
|
type: string
|
||||||
with-front-dependencies-installation:
|
with-front-dependencies-installation:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|||||||
28
.github/workflows/docker-hub.yml
vendored
28
.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
|
||||||
@@ -79,8 +85,10 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: |
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
PUBLISH_AS_MIT=false
|
||||||
|
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 }}
|
||||||
|
|
||||||
@@ -98,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
|
||||||
@@ -114,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 }}
|
||||||
|
|
||||||
@@ -123,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
|
||||||
|
|||||||
71
.github/workflows/impress-frontend.yml
vendored
71
.github/workflows/impress-frontend.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
test-front:
|
test-front:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -80,13 +80,13 @@ 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
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
run: cd src/frontend/ && yarn e2e:test --project='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
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -119,13 +119,13 @@ 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
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||||
@@ -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"
|
||||||
|
|||||||
42
.github/workflows/impress.yml
vendored
42
.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)
|
||||||
@@ -61,6 +65,26 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
lint-spell-mistakes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install codespell
|
||||||
|
run: pip install --user codespell
|
||||||
|
- name: Check for typos
|
||||||
|
run: |
|
||||||
|
codespell \
|
||||||
|
--check-filenames \
|
||||||
|
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||||
|
--skip "./git/" \
|
||||||
|
--skip "**/*.pdf" \
|
||||||
|
--skip "**/*.po" \
|
||||||
|
--skip "**/*.pot" \
|
||||||
|
--skip "**/*.json" \
|
||||||
|
--skip "**/yarn.lock"
|
||||||
|
|
||||||
lint-back:
|
lint-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -68,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.12.6"
|
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
|
||||||
@@ -165,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.12.6"
|
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
|
||||||
|
|||||||
451
CHANGELOG.md
451
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),
|
||||||
@@ -8,28 +6,386 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## Fixed
|
### Added
|
||||||
|
|
||||||
- 🔒(frontend) enhance file download security #889
|
- ✨ Add comments feature to the editor #1330
|
||||||
|
- ✨(backend) Comments on text editor #1330
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- 🧑💻(docker) add .next to .dockerignore #1055
|
||||||
|
- 🧑💻(docker) handle frontend development images with docker compose #1033
|
||||||
|
- 🧑💻(docker) add y-provider config to development environment #1057
|
||||||
|
- ⚡️(frontend) optimize document fetch error handling #1089
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) fix link definition select options linked to ancestors #846
|
||||||
|
- 🐛(frontend) table of content disappearing #982
|
||||||
|
- 🐛(frontend) fix multiple EmojiPicker #1012
|
||||||
|
- 🐛(frontend) fix meta title #1017
|
||||||
|
- 🔧(git) set LF line endings for all text files #1032
|
||||||
|
- 📝(docs) minor fixes to docs/env.md
|
||||||
|
- ✨support `_FILE` environment variables for secrets #912
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(frontend) remove Beta from logo #1095
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add endpoint checking media status #984
|
||||||
|
- ✨(backend) allow setting session cookie age via env var #977
|
||||||
|
- ✨(backend) allow theme customnization using a configuration file #948
|
||||||
|
- ✨(frontend) Add a custom callout block to the editor #892
|
||||||
|
- 🚩(frontend) version MIT only #911
|
||||||
|
- ✨(backend) integrate maleware_detection from django-lasuite #936
|
||||||
|
- 🏗️(frontend) Footer configurable #959
|
||||||
|
- 🩺(CI) add lint spell mistakes #954
|
||||||
|
- ✨(frontend) create generic theme #792
|
||||||
|
- 🛂(frontend) block edition to not connected users #945
|
||||||
|
- 🚸(frontend) Let loader during upload analyze #984
|
||||||
|
- 🚩(frontend) feature flag on blocking edition #997
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 📝(frontend) Update documentation #949
|
||||||
|
- ✅(frontend) Improve tests coverage #949
|
||||||
|
- ⬆️(docker) upgrade backend image to python 3.13 #973
|
||||||
|
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(back) remove footer endpoint #948
|
||||||
|
|
||||||
|
## [3.2.1] - 2025-05-06
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix list copy paste #943
|
||||||
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
|
|
||||||
|
## [3.2.0] - 2025-05-05
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- 🚸(backend) make document search on title accent-insensitive #874
|
- 🚸(backend) make document search on title accent-insensitive #874
|
||||||
- 🚩 add homepage feature flag #861
|
- 🚩 add homepage feature flag #861
|
||||||
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
- ✨(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
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- ⚡️(frontend) reduce unblocking time for config #867
|
- ⚡️(frontend) reduce unblocking time for config #867
|
||||||
- ♻️(frontend) bind UI with ability access #900
|
- ♻️(frontend) bind UI with ability access #900
|
||||||
|
- ♻️(frontend) use built-in Quote block #908
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(nginx) fix 404 when accessing a doc #866
|
- 🐛(nginx) fix 404 when accessing a doc #866
|
||||||
- 🔒️(drf) disable browsable HTML API renderer #919
|
- 🔒️(drf) disable browsable HTML API renderer #919
|
||||||
|
- 🔒(frontend) enhance file download security #889
|
||||||
|
- 🐛(backend) race condition create doc #633
|
||||||
|
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||||
|
|
||||||
## [3.1.0] - 2025-04-07
|
## [3.1.0] - 2025-04-07
|
||||||
|
|
||||||
@@ -65,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
|
||||||
@@ -84,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
|
||||||
@@ -107,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
|
||||||
@@ -130,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
|
||||||
@@ -158,11 +510,6 @@ and this project adheres to
|
|||||||
- ♻️(frontend) improve table pdf rendering
|
- ♻️(frontend) improve table pdf rendering
|
||||||
- 🐛(email) invitation emails in receivers language
|
- 🐛(email) invitation emails in receivers language
|
||||||
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- 🐛(backend) race condition create doc #633
|
|
||||||
|
|
||||||
## [2.2.0] - 2025-02-10
|
## [2.2.0] - 2025-02-10
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -533,7 +880,7 @@ and this project adheres to
|
|||||||
- ⚡️(e2e) unique login between tests (#80)
|
- ⚡️(e2e) unique login between tests (#80)
|
||||||
- ⚡️(CI) improve e2e job (#86)
|
- ⚡️(CI) improve e2e job (#86)
|
||||||
- ♻️(frontend) improve the error and message info ui (#93)
|
- ♻️(frontend) improve the error and message info ui (#93)
|
||||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
@@ -551,30 +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.1.0...main
|
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
|
||||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
|
||||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
|
||||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||||
|
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||||
|
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||||
|
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||||
|
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||||
|
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||||
|
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||||
|
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||||
|
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||||
|
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||||
|
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||||
|
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||||
|
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||||
|
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||||
|
|||||||
@@ -42,34 +42,38 @@ Examples of unacceptable behavior include:
|
|||||||
|
|
||||||
## Enforcement Guidelines
|
## Enforcement Guidelines
|
||||||
|
|
||||||
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
|
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct
|
||||||
|
|
||||||
## Code of Conduct:
|
## Code of Conduct:
|
||||||
|
|
||||||
1. Correction
|
### 1. Correction
|
||||||
|
|
||||||
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||||
2. Warning
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
Community Impact: A violation through a single incident or series of actions.
|
Community Impact: A violation through a single incident or series of actions.
|
||||||
|
|
||||||
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
3. Temporary Ban
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
||||||
|
|
||||||
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||||
4. Permanent Ban
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
Consequence: A permanent ban from any sort of public interaction within the community.
|
Consequence: A permanent ban from any sort of public interaction within the community.
|
||||||
Attribution
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
|
Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md).
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||||
|
|
||||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
|
||||||
|
|
||||||
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
|
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||||
|
|
||||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||||
|
|
||||||
## Help us with translations
|
## Help us with translations
|
||||||
|
|
||||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||||
Your language is not there? Request it on our Crowdin page 😊.
|
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
|
||||||
|
|
||||||
## Creating an Issue
|
## Creating an Issue
|
||||||
|
|
||||||
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
|
|||||||
|
|
||||||
`<gitmoji>(type) title description`
|
`<gitmoji>(type) title description`
|
||||||
|
|
||||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||||
* **title**: A short, descriptive title for the change.
|
* **title**: A short, descriptive title for the change (*)
|
||||||
* **description**: Include additional details about what was changed and why.
|
* **blank line after the commit title
|
||||||
|
* **description**: Include additional details on why you made the changes (**).
|
||||||
|
|
||||||
|
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
|
||||||
|
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
|
||||||
|
|
||||||
### Example Commit Message
|
### Example Commit Message
|
||||||
|
|
||||||
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
|
|||||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||||
|
|
||||||
### Don't forget to:
|
### Don't forget to:
|
||||||
- check your commits
|
- signoff your commits
|
||||||
|
- sign your commits with your key (SSH, GPG etc.)
|
||||||
|
- check your commits (see warnings above)
|
||||||
- check the linting: `make lint && make frontend-lint`
|
- check the linting: `make lint && make frontend-lint`
|
||||||
- check the tests: `make test`
|
- check the tests: `make test`
|
||||||
- add a changelog entry
|
- add a changelog entry
|
||||||
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
|
|||||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||||
|
|
||||||
Thank you for your contributions! 👍
|
Thank you for your contributions! 👍
|
||||||
|
|
||||||
|
## Contribute to BlockNote
|
||||||
|
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
|
||||||
|
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
|
||||||
|
|
||||||
|
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
|
||||||
|
|
||||||
|
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,14 +1,13 @@
|
|||||||
# Django impress
|
# Django impress
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.12.6-alpine3.20 AS base
|
FROM python:3.13.3-alpine AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
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
|
||||||
@@ -30,7 +29,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:24 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -139,6 +146,9 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
|||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core AS backend-production
|
FROM core AS backend-production
|
||||||
|
|
||||||
|
# Remove apk cache, we don't need it anymore
|
||||||
|
RUN rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Gunicorn
|
# Gunicorn
|
||||||
|
|||||||
173
Makefile
173
Makefile
@@ -35,10 +35,15 @@ 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_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
@@ -47,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
|
||||||
@@ -66,30 +71,111 @@ 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
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
pre-bootstrap: \
|
||||||
bootstrap: \
|
|
||||||
data/media \
|
data/media \
|
||||||
data/static \
|
data/static \
|
||||||
create-env-files \
|
create-env-local-files
|
||||||
build \
|
.PHONY: pre-bootstrap
|
||||||
|
|
||||||
|
post-bootstrap: \
|
||||||
migrate \
|
migrate \
|
||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build \
|
mails-build
|
||||||
run
|
.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
|
||||||
|
|
||||||
|
post-beautiful-bootstrap: ## Display a success message after bootstrap
|
||||||
|
@echo ""
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@echo "Bootstrap completed successfully!"
|
||||||
|
@echo ""
|
||||||
|
@echo "Next steps:"
|
||||||
|
@echo " - Visit http://localhost:3000 to access the application"
|
||||||
|
@echo " - Run 'make help' to see all available commands"
|
||||||
|
else
|
||||||
|
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BOLD)Next steps:$(RESET)"
|
||||||
|
@echo " • Visit http://localhost:3000 to access the application"
|
||||||
|
@echo " • Run 'make help' to see all available commands"
|
||||||
|
endif
|
||||||
|
@echo ""
|
||||||
|
.PHONY: post-beautiful-bootstrap
|
||||||
|
|
||||||
|
bootstrap: ## Prepare the project for local development
|
||||||
|
bootstrap: \
|
||||||
|
pre-beautiful-bootstrap \
|
||||||
|
pre-bootstrap \
|
||||||
|
build \
|
||||||
|
post-bootstrap \
|
||||||
|
run \
|
||||||
|
post-beautiful-bootstrap
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
|
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||||
|
bootstrap-e2e: \
|
||||||
|
pre-bootstrap \
|
||||||
|
build-e2e \
|
||||||
|
post-bootstrap \
|
||||||
|
run-e2e
|
||||||
|
.PHONY: bootstrap-e2e
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
build: cache ?= --no-cache
|
build: cache ?=
|
||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||||
@@ -103,16 +189,23 @@ build-backend: ## build the app-dev container
|
|||||||
|
|
||||||
build-yjs-provider: cache ?=
|
build-yjs-provider: cache ?=
|
||||||
build-yjs-provider: ## build the y-provider container
|
build-yjs-provider: ## build the y-provider container
|
||||||
@$(COMPOSE) build y-provider $(cache)
|
@$(COMPOSE) build y-provider-development $(cache)
|
||||||
.PHONY: build-yjs-provider
|
.PHONY: build-yjs-provider
|
||||||
|
|
||||||
build-frontend: cache ?=
|
build-frontend: cache ?=
|
||||||
build-frontend: ## build the frontend container
|
build-frontend: ## build the frontend container
|
||||||
@$(COMPOSE) build frontend $(cache)
|
@$(COMPOSE) build frontend-development $(cache)
|
||||||
.PHONY: build-frontend
|
.PHONY: build-frontend
|
||||||
|
|
||||||
|
build-e2e: cache ?=
|
||||||
|
build-e2e: ## build the e2e container
|
||||||
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
|
@$(COMPOSE_E2E) build frontend $(cache)
|
||||||
|
@$(COMPOSE_E2E) build y-provider $(cache)
|
||||||
|
.PHONY: build-e2e
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE_E2E) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
|
|
||||||
logs: ## display app-dev logs (follow mode)
|
logs: ## display app-dev logs (follow mode)
|
||||||
@@ -121,22 +214,30 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
|
|
||||||
run-backend: ## Start only the backend application and all needed services
|
run-backend: ## Start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||||
@$(COMPOSE) up --force-recreate -d y-provider
|
@$(COMPOSE) up --force-recreate -d y-provider-development
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
|
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
run:
|
run:
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend-development
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
|
run-e2e: ## start the e2e server
|
||||||
|
run-e2e:
|
||||||
|
@$(MAKE) run-backend
|
||||||
|
@$(COMPOSE_E2E) stop y-provider-development
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d frontend
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d y-provider
|
||||||
|
.PHONY: run-e2e
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
status: ## an alias for "docker compose ps"
|
||||||
@$(COMPOSE) ps
|
@$(COMPOSE_E2E) ps
|
||||||
.PHONY: status
|
.PHONY: status
|
||||||
|
|
||||||
stop: ## stop the development server using Docker
|
stop: ## stop the development server using Docker
|
||||||
@$(COMPOSE) stop
|
@$(COMPOSE_E2E) stop
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
@@ -225,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
|
||||||
@@ -315,10 +402,14 @@ frontend-lint: ## run the frontend linter
|
|||||||
.PHONY: frontend-lint
|
.PHONY: frontend-lint
|
||||||
|
|
||||||
run-frontend-development: ## Run the frontend in development mode
|
run-frontend-development: ## Run the frontend in development mode
|
||||||
@$(COMPOSE) stop frontend
|
@$(COMPOSE) stop frontend-development
|
||||||
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
|
||||||
@@ -349,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
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -1,13 +1,19 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/suitenumerique/docs">
|
<a href="https://github.com/suitenumerique/docs">
|
||||||
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
|
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
|
<a href="https://github.com/suitenumerique/docs/stargazers/">
|
||||||
|
<img src="https://img.shields.io/github/stars/suitenumerique/docs" alt="">
|
||||||
|
</a>
|
||||||
|
<a href='https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
|
||||||
|
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
|
||||||
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
|
||||||
|
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
|
||||||
|
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||||
Chat on Matrix
|
Chat on Matrix
|
||||||
@@ -20,43 +26,61 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
# La Suite Docs : Collaborative Text Editing
|
||||||
|
Docs, where your notes can become knowledge through live collaboration.
|
||||||
|
|
||||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
### Write
|
### Write
|
||||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
* 😌 Get simple, accessible online editing for your team.
|
||||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
* 💅 Create clean documents with beautiful formatting options.
|
||||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
|
||||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
|
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
|
||||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
* 🔌 Write offline! Your edits will be synced once you're back online.
|
||||||
|
* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt!
|
||||||
|
|
||||||
### Collaborate
|
### Work together
|
||||||
* 🤝 Collaborate with your team in real time
|
* 🤝 Enjoy live editing! See your team collaborate in real time.
|
||||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
|
||||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
|
||||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`
|
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||||
|
|
||||||
### Self-host
|
### Self-host
|
||||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
|
||||||
|
|
||||||
⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).
|
#### 🚀 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.
|
||||||
|
|
||||||
|
#### 🌍 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🙏
|
||||||
|
|
||||||
|
| 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 🔧
|
||||||
|
|
||||||
### Test it
|
### Test it
|
||||||
|
|
||||||
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||||
|
|
||||||
### Run it locally
|
### Run Docs locally
|
||||||
|
|
||||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
|
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
|
||||||
|
|
||||||
**Prerequisite**
|
**Prerequisite**
|
||||||
|
|
||||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ docker -v
|
$ docker -v
|
||||||
@@ -68,7 +92,7 @@ $ docker compose version
|
|||||||
Docker Compose version v2.32.4
|
Docker Compose version v2.32.4
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
|
||||||
|
|
||||||
**Project bootstrap**
|
**Project bootstrap**
|
||||||
|
|
||||||
@@ -78,20 +102,20 @@ The easiest way to start working on the project is to use [GNU Make](https://www
|
|||||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||||
```
|
```
|
||||||
|
|
||||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||||
|
|
||||||
Your Docker services should now be up and running 🎉
|
Your Docker services should now be up and running 🎉
|
||||||
|
|
||||||
You can access to the project by going to <http://localhost:3000>.
|
You can access the project by going to <http://localhost:3000>.
|
||||||
|
|
||||||
You will be prompted to log in, the default credentials are:
|
You will be prompted to log in. The default credentials are:
|
||||||
|
|
||||||
```
|
```
|
||||||
username: impress
|
username: impress
|
||||||
password: impress
|
password: impress
|
||||||
```
|
```
|
||||||
|
|
||||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make run
|
$ make run
|
||||||
@@ -117,15 +141,21 @@ 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:
|
You can create a basic demo site by running this command:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make demo
|
$ make demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, you can check all available Make rules using:
|
Finally, you can check all available Make rules using this command:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make help
|
$ make help
|
||||||
@@ -133,7 +163,7 @@ $ make help
|
|||||||
|
|
||||||
**Django admin**
|
**Django admin**
|
||||||
|
|
||||||
You can access the Django admin site at
|
You can access the Django admin site at:
|
||||||
|
|
||||||
<http://localhost:8071/admin>.
|
<http://localhost:8071/admin>.
|
||||||
|
|
||||||
@@ -145,17 +175,17 @@ $ make superuser
|
|||||||
|
|
||||||
## Feedback 🙋♂️🙋♀️
|
## Feedback 🙋♂️🙋♀️
|
||||||
|
|
||||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap 💡
|
||||||
|
|
||||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||||
|
|
||||||
## Licence 📝
|
## License 📝
|
||||||
|
|
||||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||||
|
|
||||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||||
|
|
||||||
## Contributing 🙌
|
## Contributing 🙌
|
||||||
|
|
||||||
@@ -163,9 +193,9 @@ This project is intended to be community-driven, so please, do not hesitate to [
|
|||||||
|
|
||||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||||
|
|
||||||
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
Directory structure:
|
## Directory structure:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
docs
|
docs
|
||||||
@@ -183,14 +213,14 @@ docs
|
|||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
|
||||||
|
|
||||||
|
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||||
|
|
||||||
|
|
||||||
### Gov ❤️ open source
|
### Gov ❤️ open source
|
||||||
|
|
||||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||||
|
|
||||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
|
||||||
|
|
||||||
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Security is very important to us.
|
Security is very important to us.
|
||||||
|
|
||||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
||||||
|
|
||||||
We appreciate your effort to make Docs more secure.
|
We appreciate your effort to make Docs more secure.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-05-22
|
||||||
|
|
||||||
|
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||||
|
|
||||||
|
The footer is now configurable from a customization file. To override the default one, you can
|
||||||
|
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
|
||||||
|
The customization file must be a JSON file and must follow the rules described in the
|
||||||
|
[theming documentation](docs/theming.md).
|
||||||
|
|
||||||
## [3.0.0] - 2025-03-28
|
## [3.0.0] - 2025-03-28
|
||||||
|
|
||||||
We are not using the nginx auth request anymore to access the collaboration server (`yProvider`)
|
We are not using the nginx auth request anymore to access the collaboration server (`yProvider`)
|
||||||
|
|||||||
@@ -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 = '''
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
|||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
|
|
||||||
|
|
||||||
# _set_user: set (or unset) default user id used to run docker commands
|
# _set_user: set (or unset) default user id used to run docker commands
|
||||||
@@ -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 \
|
||||||
|
|||||||
29
compose-e2e.yml
Normal file
29
compose-e2e.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: frontend-production
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-production
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
|
target: y-provider
|
||||||
|
image: impress:y-provider-production
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
|
ports:
|
||||||
|
- "4444:4444"
|
||||||
@@ -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,47 +94,15 @@ 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
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-dev
|
- app-dev
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: backend-production
|
|
||||||
args:
|
|
||||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
postgresql:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
minio:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
celery:
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
ports:
|
ports:
|
||||||
@@ -141,24 +112,25 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
app-dev:
|
app-dev:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
y-provider:
|
|
||||||
condition: service_started
|
|
||||||
keycloak:
|
keycloak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
|
|
||||||
frontend:
|
frontend-development:
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/Dockerfile
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: impress-dev
|
||||||
args:
|
args:
|
||||||
API_ORIGIN: "http://localhost:8071"
|
API_ORIGIN: "http://localhost:8071"
|
||||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
PUBLISH_AS_MIT: "false"
|
||||||
MEDIA_URL: "http://localhost:8083"
|
|
||||||
SW_DEACTIVATED: "true"
|
SW_DEACTIVATED: "true"
|
||||||
image: impress:frontend-development
|
image: impress:frontend-development
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
@@ -168,32 +140,35 @@ 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
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:18
|
image: node:22
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
volumes:
|
volumes:
|
||||||
- ".:/app"
|
- ".:/app"
|
||||||
|
|
||||||
y-provider:
|
y-provider-development:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider-development
|
||||||
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
|
image: impress:y-provider-development
|
||||||
working_dir: /app/frontend
|
|
||||||
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:
|
||||||
- ./src/frontend/:/app/frontend
|
- ./src/frontend/:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/servers/y-provider/node_modules
|
||||||
|
|
||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
@@ -206,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.e2e",
|
"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.e2e",
|
"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.e2e",
|
"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": []
|
||||||
|
|||||||
115
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
115
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
upstream docs_backend {
|
||||||
|
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream docs_frontend {
|
||||||
|
server ${FRONTEND_HOST}:3000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8083;
|
||||||
|
server_name localhost;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# increase max upload size
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# Disables server version feedback on pages and in headers
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
location @proxy_to_docs_backend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @proxy_to_docs_frontend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri @proxy_to_docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for collaboration server
|
||||||
|
location /collaboration/ws/ {
|
||||||
|
# Ensure WebSocket upgrade
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
|
||||||
|
# Set appropriate timeout for WebSocket
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
|
||||||
|
# Preserve original host and additional headers
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /collaboration/api/ {
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for media
|
||||||
|
location /media/ {
|
||||||
|
# Auth request configuration
|
||||||
|
auth_request /media-auth;
|
||||||
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
|
|
||||||
|
# Pass specific headers from the auth response
|
||||||
|
proxy_set_header Authorization $authHeader;
|
||||||
|
proxy_set_header X-Amz-Date $authDate;
|
||||||
|
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||||
|
|
||||||
|
# Get resource from Minio
|
||||||
|
proxy_pass https://${S3_HOST}/${BUCKET_NAME}/;
|
||||||
|
proxy_set_header Host ${S3_HOST};
|
||||||
|
|
||||||
|
proxy_ssl_name ${S3_HOST};
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media-auth {
|
||||||
|
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
|
||||||
|
# Prevent the body from being passed
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
docs/assets/banner-docs.png
Normal file
BIN
docs/assets/banner-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/assets/footer-configurable.png
Normal file
BIN
docs/assets/footer-configurable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
226
docs/env.md
226
docs/env.md
@@ -4,96 +4,142 @@ Here we describe all environment variables that can be set for the docs applicat
|
|||||||
|
|
||||||
## impress-backend container
|
## impress-backend container
|
||||||
|
|
||||||
These are the environmental 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 |
|
||||||
|
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||||
|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||||
|
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||||
|
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||||
|
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||||
|
| AI_MODEL | AI Model to use | |
|
||||||
|
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||||
|
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||||
|
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||||
|
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||||
|
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||||
|
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||||
|
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||||
|
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||||
|
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||||
|
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||||
|
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||||
|
| COLLABORATION_API_URL | Collaboration api host | |
|
||||||
|
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||||
|
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||||
|
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||||
|
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||||
|
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||||
|
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||||
|
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||||
|
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||||
|
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||||
|
| DB_HOST | Host of the database | localhost |
|
||||||
|
| DB_NAME | Name of the database | impress |
|
||||||
|
| DB_PASSWORD | Password to authenticate with | pass |
|
||||||
|
| DB_PORT | Port of the database | 5432 |
|
||||||
|
| DB_USER | User to authenticate with | dinum |
|
||||||
|
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||||
|
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||||
|
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||||
|
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||||
|
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
|
||||||
|
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||||
|
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||||
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||||
|
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||||
|
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||||
|
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||||
|
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||||
|
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||||
|
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||||
|
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||||
|
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||||
|
| DJANGO_SECRET_KEY | Secret key | |
|
||||||
|
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||||
|
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||||
|
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||||
|
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||||
|
| FRONTEND_THEME | Frontend theme to use | |
|
||||||
|
| LANGUAGE_CODE | Default language | en-us |
|
||||||
|
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
|
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
|
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||||
|
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||||
|
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||||
|
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||||
|
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||||
|
| MEDIA_BASE_URL | | |
|
||||||
|
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||||
|
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||||
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||||
|
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||||
|
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||||
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||||
|
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||||
|
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||||
|
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||||
|
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||||
|
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||||
|
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||||
|
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||||
|
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||||
|
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||||
|
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||||
|
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||||
|
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||||
|
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||||
|
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||||
|
| POSTHOG_KEY | Posthog key for analytics | |
|
||||||
|
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||||
|
| SENTRY_DSN | Sentry host | |
|
||||||
|
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||||
|
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||||
|
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||||
|
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||||
|
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||||
|
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||||
|
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||||
|
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||||
|
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||||
|
|
||||||
|
|
||||||
|
## impress-frontend image
|
||||||
|
|
||||||
|
These are the environment variables you can set to build the `impress-frontend` image.
|
||||||
|
|
||||||
|
Depending on how you are building the front-end application, this variable is used in different ways.
|
||||||
|
|
||||||
|
If you want to build the Docker image, this variable is used as an argument in the build command.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd src/frontend/apps/impress
|
||||||
|
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
||||||
|
```
|
||||||
|
|
||||||
| Option | Description | default |
|
| Option | Description | default |
|
||||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
|
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
|
||||||
| DJANGO_SECRET_KEY | secret key | |
|
| SW_DEACTIVATED | To not install the service worker | |
|
||||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||||
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
|
||||||
| DB_NAME | name of the database | impress |
|
Packages with licences incompatible with the MIT licence:
|
||||||
| DB_USER | user to authenticate with | dinum |
|
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||||
| DB_PASSWORD | password to authenticate with | pass |
|
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||||
| DB_HOST | host of the database | localhost |
|
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||||
| DB_PORT | port of the database | 5432 |
|
|
||||||
| MEDIA_BASE_URL | | |
|
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
|
||||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
⚠️ 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.
|
||||||
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
|
|
||||||
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
|
|
||||||
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
|
|
||||||
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
|
|
||||||
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
|
|
||||||
| LANGUAGE_CODE | default language | en-us |
|
|
||||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
|
|
||||||
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
|
|
||||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
|
||||||
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
|
|
||||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
|
||||||
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
|
|
||||||
| 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_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_EMAIL_FROM | email adress used as sender | from@example.com |
|
|
||||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
|
|
||||||
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
|
|
||||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
|
|
||||||
| SENTRY_DSN | sentry host | |
|
|
||||||
| COLLABORATION_API_URL | collaboration api host | |
|
|
||||||
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
|
|
||||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
|
||||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
|
||||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
|
||||||
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
|
|
||||||
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
|
|
||||||
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
|
|
||||||
| FRONTEND_THEME | frontend theme to use | |
|
|
||||||
| POSTHOG_KEY | posthog key for analytics | |
|
|
||||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
|
||||||
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
|
|
||||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
|
|
||||||
| OIDC_CREATE_USER | create used on OIDC | false |
|
|
||||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
|
||||||
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
|
|
||||||
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
|
|
||||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
|
||||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
|
|
||||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
|
||||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
|
||||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
|
||||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
|
|
||||||
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
|
|
||||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
|
||||||
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
|
|
||||||
| LOGOUT_REDIRECT_URL | logout redirect url | |
|
|
||||||
| OIDC_USE_NONCE | use nonce for OIDC | true |
|
|
||||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
|
||||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
|
||||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
|
||||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
|
|
||||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
|
|
||||||
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
|
||||||
| 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 |
|
|
||||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
|
||||||
| AI_API_KEY | AI key to be used for AI Base url | |
|
|
||||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
|
||||||
| AI_MODEL | AI Model to use | |
|
|
||||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
|
||||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
|
||||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
|
||||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
|
|
||||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
|
||||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
|
||||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
|
||||||
| 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 |
|
|
||||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
|
||||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
|
||||||
| REDIS_URL | cache url | redis://redis:6379/1 |
|
|
||||||
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
|
|
||||||
|
|||||||
78
docs/examples/compose/compose.yaml
Normal file
78
docs/examples/compose/compose.yaml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/postgresql
|
||||||
|
- env.d/common
|
||||||
|
environment:
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: lasuite/impress-backend:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- DJANGO_CONFIGURATION=Production
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/backend
|
||||||
|
- env.d/yprovider
|
||||||
|
- env.d/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
image: lasuite/impress-y-provider:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/yprovider
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: lasuite/impress-frontend:latest
|
||||||
|
user: "101"
|
||||||
|
entrypoint:
|
||||||
|
- /docker-entrypoint.sh
|
||||||
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
volumes:
|
||||||
|
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
88
docs/examples/compose/keycloak/README.md
Normal file
88
docs/examples/compose/keycloak/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Deploy and Configure Keycloak for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir keycloak
|
||||||
|
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||||
|
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||||
|
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update `env.d/` files
|
||||||
|
|
||||||
|
The following variables need to be updated with your own values, others can be left as is:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose keycloak instance on https
|
||||||
|
|
||||||
|
> \[!NOTE\]
|
||||||
|
> You can skip this section if you already have your own setup.
|
||||||
|
|
||||||
|
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating an OIDC Client for Docs Application
|
||||||
|
|
||||||
|
### Step 1: Create a New Realm
|
||||||
|
|
||||||
|
1. Log in to the Keycloak administration console.
|
||||||
|
2. Navigate to the realm tab and click on the "Create realm" button.
|
||||||
|
3. Enter the name of the realm - `docs`.
|
||||||
|
4. Click "Create".
|
||||||
|
|
||||||
|
#### Step 2: Create a New Client
|
||||||
|
|
||||||
|
1. Navigate to the "Clients" tab.
|
||||||
|
2. Click on the "Create client" button.
|
||||||
|
3. Enter the client ID - e.g. `docs`.
|
||||||
|
4. Enable "Client authentication" option.
|
||||||
|
6. Set the "Valid redirect URIs" to the URL of your docs application suffixed with `/*` - e.g., "https://docs.example.com/*".
|
||||||
|
1. Set the "Web Origins" to the URL of your docs application - e.g. `https://docs.example.com`.
|
||||||
|
1. Click "Save".
|
||||||
|
|
||||||
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
|
1. Go to the "Credentials" tab.
|
||||||
|
2. Copy the client ID (`docs` in this example) and the client secret.
|
||||||
36
docs/examples/compose/keycloak/compose.yaml
Normal file
36
docs/examples/compose/keycloak/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
kc_postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
volumes:
|
||||||
|
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.3
|
||||||
|
command: ["start"]
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
- env.d/keycloak
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
kc_postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
103
docs/examples/compose/minio/README.md
Normal file
103
docs/examples/compose/minio/README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Deploy and Configure Minio for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, it should not be run in production. For production environments, deploy MinIO [in a Multi-Node Multi-Drive (Distributed)](https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-multi-node-multi-drive.html#minio-mnmd) topology
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir minio
|
||||||
|
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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<Set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<Set minio root password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose MinIO instance
|
||||||
|
|
||||||
|
#### Option 1: Internal network
|
||||||
|
|
||||||
|
You may not need to expose your MinIO instance to the public if only services hosted on the same private network need to access to your MinIO instance.
|
||||||
|
|
||||||
|
You should create a docker network that will be shared between those services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create storage-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Public network
|
||||||
|
|
||||||
|
If you want to expose your MinIO instance to the public, it needs to be exposed on a domain with SSL termination. You can use our [example](../nginx-proxy/README.md) with an nginx proxy and Let's Encrypt companion for automated creation/renewal of Let's Encrypt certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
docs:
|
||||||
|
...
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
...
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example we are only exposing MinIO API service. Follow the official documentation to configure Minio WebUI.
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your minio instance is now available on https://storage.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating a user and bucket for your Docs instance
|
||||||
|
|
||||||
|
### Installing mc
|
||||||
|
|
||||||
|
Follow the [official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc) to install mc
|
||||||
|
|
||||||
|
### Step 1: Configure `mc` to connect to your MinIO Server with your root user
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc alias set minio <MINIO_SERVER_URL> <MINIO_ROOT_USER> <MINIO_ROOT_PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the values with those you have set in the previous steps
|
||||||
|
|
||||||
|
### Step 2: Create a new bucket with versioning enabled
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc mb --with-versioning minio/<your-bucket-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `your-bucket-name` with the desired name for your bucket e.g. `docs-media-storage`
|
||||||
|
|
||||||
|
### Additional notes:
|
||||||
|
|
||||||
|
For increased security you should create a dedicated user with `readwrite` access to the Bucket. In the following example we will use MinIO root user.
|
||||||
27
docs/examples/compose/minio/compose.yaml
Normal file
27
docs/examples/compose/minio/compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 300
|
||||||
|
entrypoint: ""
|
||||||
|
command: minio server /data
|
||||||
|
volumes:
|
||||||
|
- ./data/minio:/data
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
39
docs/examples/compose/nginx-proxy/README.md
Normal file
39
docs/examples/compose/nginx-proxy/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Nginx proxy with automatic SSL certificates
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
|
||||||
|
|
||||||
|
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
|
||||||
|
|
||||||
|
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir nginx-proxy
|
||||||
|
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.
|
||||||
|
|
||||||
|
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
|
||||||
|
|
||||||
|
### Step 3: Create docker network
|
||||||
|
|
||||||
|
Containers need share the same network for auto-discovery.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create proxy-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
|
||||||
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginxproxy/nginx-proxy
|
||||||
|
container_name: nginx-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- html:/usr/share/nginx/html
|
||||||
|
- certs:/etc/nginx/certs:ro
|
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
acme-companion:
|
||||||
|
image: nginxproxy/acme-companion
|
||||||
|
container_name: nginx-proxy-acme
|
||||||
|
environment:
|
||||||
|
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||||
|
volumes_from:
|
||||||
|
- nginx-proxy
|
||||||
|
volumes:
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- acme:/etc/acme.sh
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-tier:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
html:
|
||||||
|
certs:
|
||||||
|
acme:
|
||||||
@@ -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
|
||||||
@@ -82,13 +79,13 @@ backend:
|
|||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
# Exra 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.12/site-packages/certifi/cacert.pem
|
mountPath: /cert/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Exra 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
|
||||||
extraVolumes:
|
extraVolumes:
|
||||||
- name: certs
|
- name: certs
|
||||||
configMap:
|
configMap:
|
||||||
@@ -121,6 +118,22 @@ yProvider:
|
|||||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||||
COLLABORATION_SERVER_SECRET: my-secret
|
COLLABORATION_SERVER_SECRET: my-secret
|
||||||
Y_PROVIDER_API_KEY: my-secret
|
Y_PROVIDER_API_KEY: my-secret
|
||||||
|
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
|
||||||
|
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
|
||||||
|
# Mount the certificate so yProvider can establish tls with the backend
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: certs
|
||||||
|
mountPath: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
subPath: cacert.pem
|
||||||
|
|
||||||
|
extraVolumes:
|
||||||
|
- name: certs
|
||||||
|
configMap:
|
||||||
|
name: certifi
|
||||||
|
items:
|
||||||
|
- key: cacert.pem
|
||||||
|
path: cacert.pem
|
||||||
|
|
||||||
posthog:
|
posthog:
|
||||||
ingress:
|
ingress:
|
||||||
@@ -135,9 +148,6 @@ ingress:
|
|||||||
ingressCollaborationWS:
|
ingressCollaborationWS:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: impress.127.0.0.1.nip.io
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
|
||||||
|
|
||||||
ingressCollaborationApi:
|
ingressCollaborationApi:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -91,7 +91,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -105,7 +105,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -119,7 +119,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
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 🙏
|
||||||
232
docs/installation/compose.md
Normal file
232
docs/installation/compose.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 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/kubernetes.md)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A modern version of Docker and its Compose plugin.
|
||||||
|
- A domain name and DNS configured to your server.
|
||||||
|
- An Identity Provider that supports OpenID Connect protocol - we provide [an example to deploy Keycloak](../examples/compose/keycloak/README.md).
|
||||||
|
- An Object Storage that implements S3 API - we provide [an example to deploy Minio](../examples/compose/minio/README.md).
|
||||||
|
- A Postgresql database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
- A Redis database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
|
||||||
|
## Software Requirements
|
||||||
|
|
||||||
|
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
|
||||||
|
|
||||||
|
Docker Compose is included with Docker Engine:
|
||||||
|
|
||||||
|
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
|
||||||
|
for [installing Docker Engine](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
|
For older versions of Docker Engine that do not include Docker Compose:
|
||||||
|
|
||||||
|
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
|
||||||
|
|
||||||
|
## Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/env.d
|
||||||
|
cd docs
|
||||||
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
|
||||||
|
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
|
||||||
|
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
|
||||||
|
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
|
||||||
|
curl -o env.d/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
|
||||||
|
|
||||||
|
Docs configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||||
|
|
||||||
|
In this example, we assume the following services:
|
||||||
|
|
||||||
|
- OIDC provider on https://id.yourdomain.tld
|
||||||
|
- Object Storage on https://storage.yourdomain.tld
|
||||||
|
- Docs on https://docs.yourdomain.tld
|
||||||
|
- Bucket name is docs-media-storage
|
||||||
|
|
||||||
|
**Set your own values in `env.d/common`**
|
||||||
|
|
||||||
|
### OIDC
|
||||||
|
|
||||||
|
Authentication in Docs is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
|
||||||
|
|
||||||
|
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
|
||||||
|
|
||||||
|
If using Keycloak as your Identity Provider, set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Docs. By default we have set `docs` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `env.d/common`
|
||||||
|
|
||||||
|
For others OIDC providers, update the variables in `env.d/backend`.
|
||||||
|
|
||||||
|
### Object Storage
|
||||||
|
|
||||||
|
Files and media are stored in an Object Store that supports the S3 API.
|
||||||
|
|
||||||
|
For guidance, refer to our [Minio deployment example](../examples/compose/minio/README.md).
|
||||||
|
|
||||||
|
Set `AWS_S3_ACCESS_KEY_ID` and `AWS_S3_SECRET_ACCESS_KEY` with the credentials of a user with `readwrite` access to the bucket created for Docs.
|
||||||
|
|
||||||
|
### Postgresql
|
||||||
|
|
||||||
|
Docs uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
|
||||||
|
|
||||||
|
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
Docs uses Redis for caching. While an external Redis can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/backend`.
|
||||||
|
|
||||||
|
### Y Provider
|
||||||
|
|
||||||
|
The Y provider service enables collaboration through websockets.
|
||||||
|
|
||||||
|
Generates a secure key for `Y_PROVIDER_API_KEY` and `COLLABORATION_SERVER_SECRET` in ``env.d/yprovider``.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
The Docs backend is built on the Django Framework.
|
||||||
|
|
||||||
|
Generates a secure key for `DJANGO_SECRET_KEY` in `env.d/backend`.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Update the following variables in `env.d/backend` if you want to change the logging levels:
|
||||||
|
```env
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail
|
||||||
|
|
||||||
|
The following environment variables are required in `env.d/backend` for the mail service to send invitations :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
Built-in AI actions let users generate, summarize, translate, and correct content.
|
||||||
|
|
||||||
|
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
AI_API_KEY=<API key>
|
||||||
|
AI_MODEL=<model used> e.g. llama
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend theme
|
||||||
|
|
||||||
|
You can [customize your Docs instance](../theming.md) with your own theme and custom css.
|
||||||
|
|
||||||
|
The following environment variables must be set in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRONTEND_THEME=default # name of your theme built with cuningham
|
||||||
|
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Reverse proxy and SSL/TLS
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> In a production environment, configure SSL/TLS termination to run your instance on https.
|
||||||
|
|
||||||
|
If you have your own certificates and proxy setup, you can skip this part.
|
||||||
|
|
||||||
|
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
|
||||||
|
|
||||||
|
You will need to uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start Docs
|
||||||
|
|
||||||
|
You are ready to start your Docs application !
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
> [!NOTE]
|
||||||
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
|
||||||
|
|
||||||
|
## Step 5: Run the database migration and create Django admin user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<admin email>` with the email of your admin user and generate a secure password.
|
||||||
|
|
||||||
|
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
|
||||||
|
|
||||||
|
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
|
||||||
|
|
||||||
|
## How to upgrade your Docs application
|
||||||
|
|
||||||
|
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
|
||||||
|
|
||||||
|
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
|
||||||
|
|
||||||
|
### Step 1: Edit the images tag with the desired version
|
||||||
|
|
||||||
|
### Step 2: Pull the images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart your containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the database migration
|
||||||
|
Your database schema may need to be updated, run:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -133,7 +133,7 @@ OIDC_RP_SCOPES: "openid email"
|
|||||||
|
|
||||||
You can find these values in **examples/keycloak.values.yaml**
|
You can find these values in **examples/keycloak.values.yaml**
|
||||||
|
|
||||||
### Find redis server connexion values
|
### Find redis server connection values
|
||||||
|
|
||||||
Docs needs a redis so we start by deploying one:
|
Docs needs a redis so we start by deploying one:
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ keycloak-postgresql-0 1/1 Running 0 26m
|
|||||||
redis-master-0 1/1 Running 0 35s
|
redis-master-0 1/1 Running 0 35s
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find postgresql connexion values
|
### Find postgresql connection values
|
||||||
|
|
||||||
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
||||||
|
|
||||||
@@ -168,12 +168,9 @@ 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 connexion values
|
### Find s3 bucket connection values
|
||||||
|
|
||||||
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
||||||
|
|
||||||
@@ -191,7 +188,7 @@ redis-master-0 1/1 Running 0 10m
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
|
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||||
110
docs/system-requirements.md
Normal file
110
docs/system-requirements.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# La Suite Docs – System & Requirements (2025-06)
|
||||||
|
|
||||||
|
## 1. Quick-Reference Matrix (single VM / laptop)
|
||||||
|
|
||||||
|
| Scenario | RAM | vCPU | SSD | Notes |
|
||||||
|
| ------------------------- | ----- | ---- | ------- | ------------------------- |
|
||||||
|
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
|
||||||
|
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
|
||||||
|
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
|
||||||
|
|
||||||
|
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
|
||||||
|
|
||||||
|
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
|
||||||
|
|
||||||
|
## 2. Development Environment Memory Requirements
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / source |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **1 – 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
|
||||||
|
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
|
||||||
|
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
|
||||||
|
| MinIO | **2 GB (dev) / 32 GB (prod)**| Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
|
||||||
|
| Django API (+ Celery) | **0.8 – 1.5 GB** | Empirical in-house metrics |
|
||||||
|
| Next.js frontend | **0.5 – 1 GB** | Dev build chain |
|
||||||
|
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
|
||||||
|
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
|
||||||
|
|
||||||
|
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
|
||||||
|
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
|
||||||
|
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
|
||||||
|
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
|
||||||
|
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
|
||||||
|
|
||||||
|
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
|
||||||
|
|
||||||
|
## 3. Production Environment Memory Requirements
|
||||||
|
|
||||||
|
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / notes |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **2 – 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
|
||||||
|
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
|
||||||
|
| Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions |
|
||||||
|
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
|
||||||
|
| Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency |
|
||||||
|
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
|
||||||
|
| Y-Provider (y-websocket) | **200 MB – 1 GB** | Scales with concurrent document editing sessions |
|
||||||
|
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
|
||||||
|
|
||||||
|
### Production Architecture Notes
|
||||||
|
|
||||||
|
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
|
||||||
|
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
|
||||||
|
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
|
||||||
|
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
|
||||||
|
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
|
||||||
|
|
||||||
|
### Minimal Production Setup (Core Services Only)
|
||||||
|
|
||||||
|
| Service | Memory | Notes |
|
||||||
|
| ------------------------ | --------- | --------------------------------------- |
|
||||||
|
| PostgreSQL | **2 GB** | Core database |
|
||||||
|
| Django API (+ Celery) | **1.5 GB**| Backend services |
|
||||||
|
| Y-Provider | **200 MB**| Real-time collaboration |
|
||||||
|
| Nginx | **100 MB**| Static files + reverse proxy |
|
||||||
|
| Redis | **256 MB**| Session storage |
|
||||||
|
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
|
||||||
|
|
||||||
|
## 4. Recommended Software Versions
|
||||||
|
|
||||||
|
| Tool | Minimum |
|
||||||
|
| ----------------------- | ------- |
|
||||||
|
| Docker Engine / Desktop | 24.0 |
|
||||||
|
| Docker Compose | v2 |
|
||||||
|
| Git | 2.40 |
|
||||||
|
| **Node.js** | 22+ |
|
||||||
|
| **Python** | 3.13+ |
|
||||||
|
| GNU Make | 4.4 |
|
||||||
|
| Kind | 0.22 |
|
||||||
|
| Helm | 3.14 |
|
||||||
|
| kubectl | 1.29 |
|
||||||
|
| mkcert | 1.4 |
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Ports (dev defaults)
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
| --------- | --------------------- |
|
||||||
|
| 3000 | Next.js |
|
||||||
|
| 8071 | Django |
|
||||||
|
| 4444 | Y-Provider |
|
||||||
|
| 8080 | Keycloak |
|
||||||
|
| 8083 | Nginx proxy |
|
||||||
|
| 9000/9001 | MinIO |
|
||||||
|
| 15432 | PostgreSQL (main) |
|
||||||
|
| 5433 | PostgreSQL (Keycloak) |
|
||||||
|
| 1081 | MailCatcher |
|
||||||
|
|
||||||
|
## 6. Sizing Guidelines
|
||||||
|
|
||||||
|
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||||
|
|
||||||
|
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
|
||||||
|
|
||||||
|
**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate.
|
||||||
|
|
||||||
|
**Disk** – SSD; add 10 GB extra for the Docker layer cache.
|
||||||
|
|
||||||
|
**MinIO** – for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
|
||||||
@@ -30,4 +30,59 @@ body {
|
|||||||
|
|
||||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
|
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Your Docs icon** 📝
|
||||||
|
|
||||||
|
You can add your own Docs icon in the header from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
|
|
||||||
|
This configuration is optional. If not set, the default icon will be used.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Footer Configuration** 📝
|
||||||
|
|
||||||
|
The footer is configurable from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
|
|
||||||
|
`footer.default` is the fallback if the language is not supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
Below is a visual example of a configured footer ⬇️:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Custom Translations** 📝
|
||||||
|
|
||||||
|
The translations can be partially overridden from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
145
docs/troubleshoot.md
Normal file
145
docs/troubleshoot.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
## Line Ending Issues on Windows (LF/CRLF)
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
|
||||||
|
|
||||||
|
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
|
||||||
|
- **This project** uses LF line endings for consistency across all platforms
|
||||||
|
- **Git** may automatically convert line endings, causing conflicts or build failures
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Git shows files as modified even when no changes were made
|
||||||
|
- Error messages like "warning: LF will be replaced by CRLF"
|
||||||
|
- Build failures or linting errors due to line ending mismatches
|
||||||
|
|
||||||
|
### Solutions for Windows Users
|
||||||
|
|
||||||
|
#### Configure Git to Preserve LF (Recommended)
|
||||||
|
|
||||||
|
Configure Git to NOT convert line endings and preserve LF:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells Git to:
|
||||||
|
- Never convert line endings automatically
|
||||||
|
- Always use LF for line endings in working directory
|
||||||
|
|
||||||
|
|
||||||
|
#### Fix Existing Repository with Wrong Line Endings
|
||||||
|
|
||||||
|
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
|
||||||
|
|
||||||
|
1. **Configure Git first**:
|
||||||
|
```bash
|
||||||
|
git config --global core.autocrlf false
|
||||||
|
git config --global core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clone the project fresh** (recommended):
|
||||||
|
```bash
|
||||||
|
# Navigate to parent directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Remove current repository (backup your changes first!)
|
||||||
|
rm -rf docs
|
||||||
|
|
||||||
|
# Clone again with correct line endings
|
||||||
|
git clone git@github.com:suitenumerique/docs.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative**: If you have uncommitted changes and cannot re-clone:
|
||||||
|
|
||||||
|
1. **Backup your changes**:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Save changes before fixing line endings"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Remove all files from Git's index**:
|
||||||
|
```bash
|
||||||
|
git rm --cached -r .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reset Git configuration** (if not done globally):
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Re-add all files** (Git will use LF line endings):
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Commit the changes**:
|
||||||
|
```bash
|
||||||
|
git commit -m "✏️(project) Fix line endings to LF"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend File Watching Issues on Windows
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
|
||||||
|
|
||||||
|
- **Docker on Windows** has known limitations with file change detection
|
||||||
|
- **Node.js file watchers** may not detect changes properly on Windows filesystem
|
||||||
|
- **Hot reloading** fails to trigger when files are modified
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Changes to frontend code aren't detected automatically
|
||||||
|
- Hot module replacement doesn't work as expected
|
||||||
|
- Need to manually restart the frontend container after code changes
|
||||||
|
- Console shows no reaction when saving files
|
||||||
|
|
||||||
|
### Solution: Enable WATCHPACK_POLLING
|
||||||
|
|
||||||
|
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
|
||||||
|
|
||||||
|
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend-development:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: impress-dev
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-development
|
||||||
|
environment:
|
||||||
|
- WATCHPACK_POLLING=true # Add this line for Windows users
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart your containers**:
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
|
||||||
|
- Polling periodically checks for file changes rather than relying on OS-level file events
|
||||||
|
- This is more reliable on Windows but slightly increases CPU usage
|
||||||
|
- Changes to your frontend code should now be detected properly, enabling hot reloading
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
|
||||||
@@ -56,14 +56,16 @@ AI_API_KEY=password
|
|||||||
AI_MODEL=llama
|
AI_MODEL=llama
|
||||||
|
|
||||||
# Collaboration
|
# Collaboration
|
||||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
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/
|
||||||
|
|
||||||
# Frontend
|
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||||
FRONTEND_THEME=default
|
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
|
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||||
FRONTEND_FOOTER_FEATURE_ENABLED=True
|
|
||||||
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
|
# Theme customization
|
||||||
|
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# For the CI job test-e2e
|
# For the CI job test-e2e
|
||||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
|
||||||
BURST_THROTTLE_RATES="200/minute"
|
BURST_THROTTLE_RATES="200/minute"
|
||||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
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
|
||||||
65
env.d/production.dist/backend
Normal file
65
env.d/production.dist/backend
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
## Django
|
||||||
|
DJANGO_ALLOWED_HOSTS=${DOCS_HOST}
|
||||||
|
DJANGO_SECRET_KEY=<generate a random key>
|
||||||
|
DJANGO_SETTINGS_MODULE=impress.settings
|
||||||
|
DJANGO_CONFIGURATION=Production
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# Set to DEBUG level for dev only
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||||
|
|
||||||
|
# Python
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||||
|
|
||||||
|
# Media
|
||||||
|
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
|
||||||
|
AWS_S3_ACCESS_KEY_ID=<s3 access key>
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=<s3 secret key>
|
||||||
|
AWS_STORAGE_BUCKET_NAME=${BUCKET_NAME}
|
||||||
|
MEDIA_BASE_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
|
||||||
|
OIDC_RP_CLIENT_ID=<client_id>
|
||||||
|
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||||
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
|
OIDC_RP_SCOPES="openid email"
|
||||||
|
#OIDC_USERINFO_SHORTNAME_FIELD
|
||||||
|
#OIDC_USERINFO_FULLNAME_FIELDS
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
|
||||||
|
LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
|
||||||
|
|
||||||
|
# AI
|
||||||
|
#AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
#AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
#AI_API_KEY=<API key>
|
||||||
|
#AI_MODEL=<model used> e.g. llama
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
#FRONTEND_THEME=mytheme
|
||||||
|
#FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css
|
||||||
|
#FRONTEND_FOOTER_FEATURE_ENABLED=true
|
||||||
|
#FRONTEND_URL_JSON_FOOTER=https://docs.domain.tld/contents/footer-demo.json
|
||||||
9
env.d/production.dist/common
Normal file
9
env.d/production.dist/common
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DOCS_HOST=docs.domain.tld
|
||||||
|
KEYCLOAK_HOST=id.domain.tld
|
||||||
|
S3_HOST=storage.domain.tld
|
||||||
|
BACKEND_HOST=backend
|
||||||
|
FRONTEND_HOST=frontend
|
||||||
|
YPROVIDER_HOST=y-provider
|
||||||
|
BUCKET_NAME=docs-media-storage
|
||||||
|
REALM_NAME=docs
|
||||||
|
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
|
||||||
13
env.d/production.dist/kc_postgresql
Normal file
13
env.d/production.dist/kc_postgresql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=keycloak
|
||||||
|
POSTGRES_USER=keycloak
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
# Keycloak postgresql configuration
|
||||||
|
KC_DB=postgres
|
||||||
|
KC_DB_SCHEMA=public
|
||||||
|
KC_DB_HOST=postgresql
|
||||||
|
KC_DB_NAME=${POSTGRES_DB}
|
||||||
|
KC_DB_USER=${POSTGRES_USER}
|
||||||
|
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
8
env.d/production.dist/keycloak
Normal file
8
env.d/production.dist/keycloak
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Keycloak admin user
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
|
||||||
|
# Keycloak configuration
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
|
||||||
|
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
|
||||||
11
env.d/production.dist/postgresql
Normal file
11
env.d/production.dist/postgresql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# App database configuration
|
||||||
|
DB_HOST=postgresql
|
||||||
|
DB_NAME=docs
|
||||||
|
DB_USER=docs
|
||||||
|
DB_PASSWORD=<generate a secure password>
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=docs
|
||||||
|
POSTGRES_USER=docs
|
||||||
|
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
7
env.d/production.dist/yprovider
Normal file
7
env.d/production.dist/yprovider
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
|
||||||
|
Y_PROVIDER_API_KEY=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_SECRET=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
|
||||||
|
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_LOGGING=true
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||||
|
"schedule": ["before 7am on monday"],
|
||||||
|
"prCreation": "not-pending",
|
||||||
|
"rebaseWhen": "conflicted",
|
||||||
|
"updateNotScheduled": false,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -10,21 +14,23 @@
|
|||||||
"matchPackageNames": []
|
"matchPackageNames": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "allowed django versions",
|
"groupName": "allowed redis versions",
|
||||||
"matchManagers": [
|
"matchManagers": ["pep621"],
|
||||||
"pep621"
|
"matchPackageNames": ["redis"],
|
||||||
],
|
"allowedVersions": "<6.0.0"
|
||||||
"matchPackageNames": [
|
},
|
||||||
"Django"
|
{
|
||||||
],
|
"groupName": "allowed pylint versions",
|
||||||
"allowedVersions": "<5.2"
|
"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": [
|
||||||
"eslint",
|
"docx",
|
||||||
"fetch-mock",
|
"fetch-mock",
|
||||||
"node",
|
"node",
|
||||||
"node-fetch",
|
"node-fetch",
|
||||||
|
|||||||
0
secu-audit.md
Normal file
0
secu-audit.md
Normal file
@@ -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):
|
||||||
"""
|
"""
|
||||||
@@ -517,16 +624,17 @@ class FileUploadSerializer(serializers.Serializer):
|
|||||||
mime = magic.Magic(mime=True)
|
mime = magic.Magic(mime=True)
|
||||||
magic_mime_type = mime.from_buffer(file.read(1024))
|
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||||
file.seek(0) # Reset file pointer to the beginning after reading
|
file.seek(0) # Reset file pointer to the beginning after reading
|
||||||
|
self.context["is_unsafe"] = False
|
||||||
|
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
|
||||||
|
self.context["is_unsafe"] = (
|
||||||
|
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
self.context["is_unsafe"] = (
|
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||||
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
|
||||||
)
|
|
||||||
|
|
||||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
# Try guessing a coherent extension from the mimetype
|
||||||
|
if extension_mime_type != magic_mime_type:
|
||||||
# Try guessing a coherent extension from the mimetype
|
self.context["is_unsafe"] = True
|
||||||
if extension_mime_type != magic_mime_type:
|
|
||||||
self.context["is_unsafe"] = True
|
|
||||||
|
|
||||||
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
||||||
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
||||||
@@ -642,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):
|
||||||
@@ -664,6 +775,52 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSerializer(serializers.Serializer):
|
||||||
|
"""Serializer validating role choices."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=models.RoleChoices.choices, required=False, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for creating a document ask for access."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
default=models.RoleChoices.READER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for document ask for access model"""
|
||||||
|
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"role",
|
||||||
|
"created_at",
|
||||||
|
"abilities",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "document", "user", "role", "created_at", "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 {}
|
||||||
|
|
||||||
|
|
||||||
class VersionFilterSerializer(serializers.Serializer):
|
class VersionFilterSerializer(serializers.Serializer):
|
||||||
"""Validate version filters applied to the list endpoint."""
|
"""Validate version filters applied to the list endpoint."""
|
||||||
|
|
||||||
@@ -735,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}
|
||||||
@@ -3,6 +3,7 @@ Core application enums declaration
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
from django.conf import global_settings, settings
|
from django.conf import global_settings, settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -38,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices):
|
|||||||
LAST_SIBLING = "last-sibling", _("Last sibling")
|
LAST_SIBLING = "last-sibling", _("Last sibling")
|
||||||
LEFT = "left", _("Left")
|
LEFT = "left", _("Left")
|
||||||
RIGHT = "right", _("Right")
|
RIGHT = "right", _("Right")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAttachmentStatus(StrEnum):
|
||||||
|
"""Defines the possible statuses for an attachment."""
|
||||||
|
|
||||||
|
PROCESSING = "processing"
|
||||||
|
READY = "ready"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: S311
|
|
||||||
"""
|
"""
|
||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
@@ -35,6 +34,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
# Skip postgeneration save, no save is made in the postgeneration methods.
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
@@ -149,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):
|
||||||
@@ -158,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."""
|
||||||
@@ -181,6 +191,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
|
|||||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake document ask for access for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
|
||||||
|
document = factory.SubFactory(DocumentFactory)
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to create templates"""
|
"""A factory to create templates"""
|
||||||
|
|
||||||
@@ -235,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)
|
||||||
|
|||||||
52
src/backend/core/malware_detection.py
Normal file
52
src/backend/core/malware_detection.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Malware detection callbacks"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
|
from lasuite.malware_detection.enums import ReportStatus
|
||||||
|
|
||||||
|
from core.enums import DocumentAttachmentStatus
|
||||||
|
from core.models import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
security_logger = logging.getLogger("docs.security")
|
||||||
|
|
||||||
|
|
||||||
|
def malware_detection_callback(file_path, status, error_info, **kwargs):
|
||||||
|
"""Malware detection callback"""
|
||||||
|
|
||||||
|
if status == ReportStatus.SAFE:
|
||||||
|
logger.info("File %s is safe", file_path)
|
||||||
|
# Get existing metadata
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
bucket_name = default_storage.bucket_name
|
||||||
|
head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path)
|
||||||
|
metadata = head_resp.get("Metadata", {})
|
||||||
|
metadata.update({"status": DocumentAttachmentStatus.READY})
|
||||||
|
# Update status in metadata
|
||||||
|
s3_client.copy_object(
|
||||||
|
Bucket=bucket_name,
|
||||||
|
CopySource={"Bucket": bucket_name, "Key": file_path},
|
||||||
|
Key=file_path,
|
||||||
|
ContentType=head_resp.get("ContentType"),
|
||||||
|
Metadata=metadata,
|
||||||
|
MetadataDirective="REPLACE",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
document_id = kwargs.get("document_id")
|
||||||
|
security_logger.warning(
|
||||||
|
"File %s for document %s is infected with malware. Error info: %s",
|
||||||
|
file_path,
|
||||||
|
document_id,
|
||||||
|
error_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the file from the document and change the status to unsafe
|
||||||
|
document = Document.objects.get(pk=document_id)
|
||||||
|
document.attachments.remove(file_path)
|
||||||
|
document.save(update_fields=["attachments"])
|
||||||
|
|
||||||
|
# Delete the file from the storage
|
||||||
|
default_storage.delete(file_path)
|
||||||
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
|
||||||
@@ -504,7 +504,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="documentaccess",
|
model_name="documentaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="templateaccess",
|
model_name="templateaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-06-18 10:02
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0021_activate_unaccent_extension"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentAskForAccess",
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Document ask for access",
|
||||||
|
"verbose_name_plural": "Document ask for accesses",
|
||||||
|
"db_table": "impress_document_ask_for_access",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_document_ask_for_access_user",
|
||||||
|
violation_error_message="This user has already asked for access to this document.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||||
@@ -44,7 +45,7 @@ AI_ACTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AI_TRANSLATE = (
|
AI_TRANSLATE = (
|
||||||
"Keep the same html stucture and formatting. "
|
"Keep the same html structure and formatting. "
|
||||||
"Translate the content in the html to the specified language {language:s}. "
|
"Translate the content in the html to the specified language {language:s}. "
|
||||||
"Check the translation for accuracy and make any necessary corrections. "
|
"Check the translation for accuracy and make any necessary corrections. "
|
||||||
"Do not provide any other information."
|
"Do not provide any other information."
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class CollaborationService:
|
|||||||
def reset_connections(self, room, user_id=None):
|
def reset_connections(self, room, user_id=None):
|
||||||
"""
|
"""
|
||||||
Reset connections of a room in the collaboration server.
|
Reset connections of a room in the collaboration server.
|
||||||
Reseting a connection means that the user will be disconnected and will
|
Resetting a connection means that the user will be disconnected and will
|
||||||
have to reconnect to the collaboration server, with updated rights.
|
have to reconnect to the collaboration server, with updated rights.
|
||||||
"""
|
"""
|
||||||
endpoint = "reset-connections"
|
endpoint = "reset-connections"
|
||||||
@@ -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,25 +0,0 @@
|
|||||||
"""Config services."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_footer_json(footer_json_url: str) -> dict:
|
|
||||||
"""
|
|
||||||
Fetches the footer JSON from the given URL."
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
footer_json = response.json()
|
|
||||||
|
|
||||||
return footer_json
|
|
||||||
except (requests.RequestException, ValueError) as e:
|
|
||||||
logger.error("Failed to fetch footer JSON: %s", e)
|
|
||||||
return {}
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
0
src/backend/core/tasks/__init__.py
Normal file
0
src/backend/core/tasks/__init__.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Send mail using celery task."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
from impress.celery_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def send_ask_for_access_mail(ask_for_access_id):
|
||||||
|
"""Send mail using celery task."""
|
||||||
|
# Send email to document owners/admins
|
||||||
|
ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id)
|
||||||
|
owner_admin_accesses = models.DocumentAccess.objects.filter(
|
||||||
|
document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES
|
||||||
|
).select_related("user")
|
||||||
|
|
||||||
|
for access in owner_admin_accesses:
|
||||||
|
if access.user and access.user.email:
|
||||||
|
ask_for_access.send_ask_for_access_email(
|
||||||
|
access.user.email,
|
||||||
|
access.user.language or settings.LANGUAGE_CODE,
|
||||||
|
)
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
|||||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||||
existing_user = factories.UserFactory()
|
existing_user = factories.UserFactory()
|
||||||
|
|
||||||
# Build an invitation to the email of an exising identity in the db
|
# Build an invitation to the email of an existing identity in the db
|
||||||
invitation_values = {
|
invitation_values = {
|
||||||
"email": existing_user.email,
|
"email": existing_user.email,
|
||||||
"role": random.choice(models.RoleChoices.values),
|
"role": random.choice(models.RoleChoices.values),
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
|
|||||||
@patch("openai.resources.chat.completions.Completions.create")
|
@patch("openai.resources.chat.completions.Completions.create")
|
||||||
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
||||||
"""
|
"""
|
||||||
Autenticated who are not related to a document should be able to request AI transform
|
Authenticated who are not related to a document should be able to request AI transform
|
||||||
if the link reach and role permit it.
|
if the link reach and role permit it.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Keep the same html stucture and formatting. "
|
"Keep the same html structure and formatting. "
|
||||||
"Translate the content in the html to the specified language Spanish. "
|
"Translate the content in the html to the specified language Spanish. "
|
||||||
"Check the translation for accuracy and make any necessary corrections. "
|
"Check the translation for accuracy and make any necessary corrections. "
|
||||||
"Do not provide any other information."
|
"Do not provide any other information."
|
||||||
@@ -172,7 +172,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
|
|||||||
@patch("openai.resources.chat.completions.Completions.create")
|
@patch("openai.resources.chat.completions.Completions.create")
|
||||||
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
||||||
"""
|
"""
|
||||||
Autenticated who are not related to a document should be able to request AI translate
|
Authenticated who are not related to a document should be able to request AI translate
|
||||||
if the link reach and role permit it.
|
if the link reach and role permit it.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -197,7 +197,7 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Keep the same html stucture and formatting. "
|
"Keep the same html structure and formatting. "
|
||||||
"Translate the content in the html to the "
|
"Translate the content in the html to the "
|
||||||
"specified language Colombian Spanish. "
|
"specified language Colombian Spanish. "
|
||||||
"Check the translation for accuracy and make any necessary corrections. "
|
"Check the translation for accuracy and make any necessary corrections. "
|
||||||
@@ -274,7 +274,7 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Keep the same html stucture and formatting. "
|
"Keep the same html structure and formatting. "
|
||||||
"Translate the content in the html to the "
|
"Translate the content in the html to the "
|
||||||
"specified language Colombian Spanish. "
|
"specified language Colombian Spanish. "
|
||||||
"Check the translation for accuracy and make any necessary corrections. "
|
"Check the translation for accuracy and make any necessary corrections. "
|
||||||
|
|||||||
@@ -0,0 +1,892 @@
|
|||||||
|
"""Test API for document ask for access."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.serializers import UserSerializer
|
||||||
|
from core.factories import (
|
||||||
|
DocumentAskForAccessFactory,
|
||||||
|
DocumentFactory,
|
||||||
|
UserDocumentAccessFactory,
|
||||||
|
UserFactory,
|
||||||
|
)
|
||||||
|
from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
## Create
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_anonymous():
|
||||||
|
"""Anonymous users should not be able to create a document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_invalid_document_id():
|
||||||
|
"""Invalid document ID should return a 404 error."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create a document ask for access.
|
||||||
|
An email should be sent to document owners and admins to notify them.
|
||||||
|
"""
|
||||||
|
owner_user = UserFactory(language="en-us")
|
||||||
|
admin_user = UserFactory(language="en-us")
|
||||||
|
document = DocumentFactory(
|
||||||
|
users=[
|
||||||
|
(owner_user, RoleChoices.OWNER),
|
||||||
|
(admin_user, RoleChoices.ADMIN),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=RoleChoices.READER,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
# Verify emails were sent to both owner and admin
|
||||||
|
assert len(mail.outbox) == 2
|
||||||
|
|
||||||
|
# Check that emails were sent to the right recipients
|
||||||
|
email_recipients = [email.to[0] for email in mail.outbox]
|
||||||
|
assert owner_user.email in email_recipients
|
||||||
|
assert admin_user.email in email_recipients
|
||||||
|
|
||||||
|
# Check email content for both users
|
||||||
|
for email in mail.outbox:
|
||||||
|
email_content = " ".join(email.body.split())
|
||||||
|
email_subject = " ".join(email.subject.split())
|
||||||
|
|
||||||
|
# Check that the requesting user's name is in the email
|
||||||
|
user_name = user.full_name or user.email
|
||||||
|
assert user_name.lower() in email_content.lower()
|
||||||
|
|
||||||
|
# Check that the subject mentions access request
|
||||||
|
assert "access" in email_subject.lower()
|
||||||
|
|
||||||
|
# Check that the document title is mentioned if it exists
|
||||||
|
if document.title:
|
||||||
|
assert document.title.lower() in email_subject.lower()
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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": role},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_owner_role():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to create a document ask for access with the owner role.
|
||||||
|
"""
|
||||||
|
document = DocumentFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.OWNER},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"role": ['"owner" is not a valid choice.']}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
|
||||||
|
"""Authenticated users with existing access can ask for access with a different role."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, RoleChoices.READER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=RoleChoices.EDITOR,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access():
|
||||||
|
"""
|
||||||
|
Authenticated users with existing ask for access can not ask for a new access on this document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, RoleChoices.READER)])
|
||||||
|
DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "You already ask to access to this document."}
|
||||||
|
|
||||||
|
|
||||||
|
## List
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_anonymous():
|
||||||
|
"""Anonymous users should not be able to list document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated():
|
||||||
|
"""Authenticated users should be able to list document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
"""Authenticated users should be able to list their own document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
user_data = UserSerializer(instance=user).data
|
||||||
|
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=user, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 1,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": user_data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": False,
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated_other_document():
|
||||||
|
"""Authenticated users should not be able to list document ask for access of other documents."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
other_document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=other_document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to list document ask for access."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
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."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_accesses = DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
expected_set_role_to = [
|
||||||
|
RoleChoices.READER,
|
||||||
|
RoleChoices.COMMENTER,
|
||||||
|
RoleChoices.EDITOR,
|
||||||
|
RoleChoices.ADMIN,
|
||||||
|
]
|
||||||
|
if role == RoleChoices.OWNER:
|
||||||
|
expected_set_role_to.append(RoleChoices.OWNER)
|
||||||
|
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 3,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": UserSerializer(instance=document_ask_for_access.user).data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": True,
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"set_role_to": expected_set_role_to,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for document_ask_for_access in document_ask_for_accesses
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_retrieve_anonymous():
|
||||||
|
"""Anonymous users should not be able to retrieve document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated():
|
||||||
|
"""Authenticated users should not be able to retrieve document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to retrieve document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to retrieve document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
user_data = UserSerializer(instance=document_ask_for_access.user).data
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
expected_set_role_to = [
|
||||||
|
RoleChoices.READER,
|
||||||
|
RoleChoices.COMMENTER,
|
||||||
|
RoleChoices.EDITOR,
|
||||||
|
RoleChoices.ADMIN,
|
||||||
|
]
|
||||||
|
if role == RoleChoices.OWNER:
|
||||||
|
expected_set_role_to.append(RoleChoices.OWNER)
|
||||||
|
assert response.json() == {
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": user_data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": True,
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_delete_anonymous():
|
||||||
|
"""Anonymous users should not be able to delete document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated():
|
||||||
|
"""Authenticated users should not be able to delete document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to delete document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_delete_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to delete document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
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_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
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_accept_anonymous():
|
||||||
|
"""Anonymous users should not be able to accept document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated():
|
||||||
|
"""Authenticated users should not be able to accept document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to accept document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
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/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to accept document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
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/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
assert DocumentAccess.objects.filter(
|
||||||
|
document=document, user=document_ask_for_access.user, role=RoleChoices.READER
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_specific_role(role):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access with a specific role.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
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.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
assert DocumentAccess.objects.filter(
|
||||||
|
document=document, user=document_ask_for_access.user, role=RoleChoices.EDITOR
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access(
|
||||||
|
role,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access and update the access.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_access = UserDocumentAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=document_access.user, role=RoleChoices.EDITOR
|
||||||
|
)
|
||||||
|
|
||||||
|
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.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
document_access.refresh_from_db()
|
||||||
|
assert document_access.role == RoleChoices.EDITOR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access_with_specific_role(
|
||||||
|
role,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access and update the access
|
||||||
|
with a specific role.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_access = UserDocumentAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=document_access.user, role=RoleChoices.EDITOR
|
||||||
|
)
|
||||||
|
|
||||||
|
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.ADMIN},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
document_access.refresh_from_db()
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -4,6 +4,8 @@ Test file uploads API endpoint for users in impress's core app.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
@@ -12,6 +14,7 @@ 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.viewsets import malware_detection
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
from core.tests.conftest import TEAM, USER, VIA
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -59,12 +62,17 @@ def test_api_documents_attachment_upload_anonymous_success():
|
|||||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||||
|
|
||||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
|
||||||
file_path = response.json()["file"]
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
match = pattern.search(file_path)
|
match = pattern.search(file_path)
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
# Validate that file_id is a valid UUID
|
# Validate that file_id is a valid UUID
|
||||||
@@ -74,12 +82,13 @@ def test_api_documents_attachment_upload_anonymous_success():
|
|||||||
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
||||||
|
|
||||||
# Now, check the metadata of the uploaded file
|
# Now, check the metadata of the uploaded file
|
||||||
key = file_path.replace("/media", "")
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
file_head = default_storage.connection.meta.client.head_object(
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
Bucket=default_storage.bucket_name, Key=key
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
)
|
)
|
||||||
|
|
||||||
assert file_head["Metadata"] == {"owner": "None"}
|
assert file_head["Metadata"] == {"owner": "None", "status": "processing"}
|
||||||
assert file_head["ContentType"] == "image/png"
|
assert file_head["ContentType"] == "image/png"
|
||||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||||
|
|
||||||
@@ -127,7 +136,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
|
|||||||
)
|
)
|
||||||
def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||||
"""
|
"""
|
||||||
Autenticated users who are not related to a document should be able to upload
|
Authenticated users who are not related to a document should be able to upload
|
||||||
a file when the link reach and role permit it.
|
a file when the link reach and role permit it.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -139,14 +148,24 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
|||||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||||
|
|
||||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
response = client.post(url, {"file": file}, format="multipart")
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
|
||||||
match = pattern.search(response.json()["file"])
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
|
match = pattern.search(file_path)
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
|
|
||||||
|
mock_analyse_file.assert_called_once_with(
|
||||||
|
f"{document.id!s}/attachments/{file_id!s}.png", document_id=document.id
|
||||||
|
)
|
||||||
|
|
||||||
# Validate that file_id is a valid UUID
|
# Validate that file_id is a valid UUID
|
||||||
uuid.UUID(file_id)
|
uuid.UUID(file_id)
|
||||||
|
|
||||||
@@ -210,12 +229,17 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
|||||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||||
|
|
||||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
response = client.post(url, {"file": file}, format="multipart")
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
file_path = response.json()["file"]
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
|
||||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
match = pattern.search(file_path)
|
match = pattern.search(file_path)
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
|
|
||||||
@@ -226,11 +250,12 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
|||||||
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
||||||
|
|
||||||
# Now, check the metadata of the uploaded file
|
# Now, check the metadata of the uploaded file
|
||||||
key = file_path.replace("/media", "")
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
file_head = default_storage.connection.meta.client.head_object(
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
Bucket=default_storage.bucket_name, Key=key
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
)
|
)
|
||||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
assert file_head["Metadata"] == {"owner": str(user.id), "status": "processing"}
|
||||||
assert file_head["ContentType"] == "image/png"
|
assert file_head["ContentType"] == "image/png"
|
||||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||||
|
|
||||||
@@ -255,7 +280,7 @@ def test_api_documents_attachment_upload_invalid(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||||
"""The uploaded file should not exceeed the maximum size in settings."""
|
"""The uploaded file should not exceed the maximum size in settings."""
|
||||||
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -304,12 +329,18 @@ def test_api_documents_attachment_upload_fix_extension(
|
|||||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
|
|
||||||
file = SimpleUploadedFile(name=name, content=content)
|
file = SimpleUploadedFile(name=name, content=content)
|
||||||
response = client.post(url, {"file": file}, format="multipart")
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
file_path = response.json()["file"]
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.{extension:s}")
|
||||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
|
|
||||||
match = pattern.search(file_path)
|
match = pattern.search(file_path)
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
|
|
||||||
@@ -324,11 +355,16 @@ def test_api_documents_attachment_upload_fix_extension(
|
|||||||
uuid.UUID(file_id)
|
uuid.UUID(file_id)
|
||||||
|
|
||||||
# Now, check the metadata of the uploaded file
|
# Now, check the metadata of the uploaded file
|
||||||
key = file_path.replace("/media", "")
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
file_head = default_storage.connection.meta.client.head_object(
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
Bucket=default_storage.bucket_name, Key=key
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
)
|
)
|
||||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
assert file_head["Metadata"] == {
|
||||||
|
"owner": str(user.id),
|
||||||
|
"is_unsafe": "true",
|
||||||
|
"status": "processing",
|
||||||
|
}
|
||||||
assert file_head["ContentType"] == content_type
|
assert file_head["ContentType"] == content_type
|
||||||
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
|
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
|
||||||
|
|
||||||
@@ -364,12 +400,17 @@ def test_api_documents_attachment_upload_unsafe():
|
|||||||
file = SimpleUploadedFile(
|
file = SimpleUploadedFile(
|
||||||
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
||||||
)
|
)
|
||||||
response = client.post(url, {"file": file}, format="multipart")
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
file_path = response.json()["file"]
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
|
||||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
match = pattern.search(file_path)
|
match = pattern.search(file_path)
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
|
|
||||||
@@ -381,11 +422,73 @@ def test_api_documents_attachment_upload_unsafe():
|
|||||||
file_id = file_id.replace("-unsafe", "")
|
file_id = file_id.replace("-unsafe", "")
|
||||||
uuid.UUID(file_id)
|
uuid.UUID(file_id)
|
||||||
|
|
||||||
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
# Now, check the metadata of the uploaded file
|
# Now, check the metadata of the uploaded file
|
||||||
key = file_path.replace("/media", "")
|
|
||||||
file_head = default_storage.connection.meta.client.head_object(
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
Bucket=default_storage.bucket_name, Key=key
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
)
|
)
|
||||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
assert file_head["Metadata"] == {
|
||||||
assert file_head["ContentType"] == "application/octet-stream"
|
"owner": str(user.id),
|
||||||
|
"is_unsafe": "true",
|
||||||
|
"status": "processing",
|
||||||
|
}
|
||||||
|
# Depending the libmagic version, the content type may change.
|
||||||
|
assert file_head["ContentType"] in [
|
||||||
|
"application/x-dosexec",
|
||||||
|
"application/octet-stream",
|
||||||
|
]
|
||||||
|
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_attachment_upload_unsafe_mime_types_disabled(settings):
|
||||||
|
"""A file with an unsafe mime type but checking disabled should not be tagged as unsafe."""
|
||||||
|
settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = False
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||||
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
|
|
||||||
|
file = SimpleUploadedFile(
|
||||||
|
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
||||||
|
)
|
||||||
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
|
||||||
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
|
match = pattern.search(file_path)
|
||||||
|
file_id = match.group(1)
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
|
||||||
|
|
||||||
|
assert "-unsafe" not in file_id
|
||||||
|
# Validate that file_id is a valid UUID
|
||||||
|
uuid.UUID(file_id)
|
||||||
|
|
||||||
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
|
# Now, check the metadata of the uploaded file
|
||||||
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
|
)
|
||||||
|
assert file_head["Metadata"] == {
|
||||||
|
"owner": str(user.id),
|
||||||
|
"status": "processing",
|
||||||
|
}
|
||||||
|
# Depending the libmagic version, the content type may change.
|
||||||
|
assert file_head["ContentType"] in [
|
||||||
|
"application/x-dosexec",
|
||||||
|
"application/octet-stream",
|
||||||
|
]
|
||||||
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||||
|
|||||||
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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user