mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-25 17:15:01 +02:00
Compare commits
565 Commits
documentat
...
feature/ov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1b41cc4c4 | ||
|
|
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 | ||
|
|
9b2f7966f6 | ||
|
|
5ad30b404d | ||
|
|
12524f35b7 | ||
|
|
f8a40cf8cc | ||
|
|
c32fdb67ac | ||
|
|
7f2a21cdc9 | ||
|
|
4ad917906c | ||
|
|
9ca79688c9 | ||
|
|
7f0eb9117e | ||
|
|
2557c6bc77 | ||
|
|
df173c3ce6 | ||
|
|
b58c991c81 | ||
|
|
96f6aeea60 | ||
|
|
9465f1a6ec | ||
|
|
98f11ff8ac | ||
|
|
b29daa2d77 | ||
|
|
5cdbdbf215 | ||
|
|
5268699d50 | ||
|
|
cdafe6fd33 | ||
|
|
4307b4f433 | ||
|
|
3bf33d202a | ||
|
|
101cef7d70 | ||
|
|
419079ac69 |
@@ -34,3 +34,4 @@ db.sqlite3
|
||||
|
||||
# Frontend
|
||||
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
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
labels: ["bug", "triage"]
|
||||
---
|
||||
|
||||
## 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**
|
||||
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!
|
||||
|
||||
**Environment**
|
||||
- Impress version:
|
||||
- Platform:
|
||||
- Docs version:
|
||||
- Instance url:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- 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
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
labels: ["feature", "triage"]
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**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).
|
||||
Maybe a screenshot or design?
|
||||
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
|
||||
Maybe add a screenshot or design?
|
||||
|
||||
**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! -->
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
@@ -1,17 +1,13 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
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! ^
|
||||
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).
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
Description...
|
||||
Describe the purpose of this pull request.
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
- [ ] item 1...
|
||||
- [ ] item 2...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
## External contributions
|
||||
|
||||
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:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
|
||||
7
.github/workflows/crowdin_upload.yml
vendored
7
.github/workflows/crowdin_upload.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -23,9 +23,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
|
||||
2
.github/workflows/dependencies.yml
vendored
2
.github/workflows/dependencies.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '20.x'
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
|
||||
39
.github/workflows/docker-hub.yml
vendored
39
.github/workflows/docker-hub.yml
vendored
@@ -31,8 +31,11 @@ jobs:
|
||||
images: lasuite/impress-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -46,7 +49,7 @@ jobs:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -56,6 +59,14 @@ jobs:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout custom code repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'AntoLC/docs-customized'
|
||||
ref: 'main'
|
||||
path: docs-custom
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -64,13 +75,16 @@ jobs:
|
||||
images: lasuite/impress-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production --build-arg CUSTOM_CODE=docs-custom'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
@@ -79,8 +93,11 @@ jobs:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
CUSTOM_CODE=docs-custom
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -98,7 +115,7 @@ jobs:
|
||||
images: lasuite/impress-y-provider
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
@@ -114,7 +131,7 @@ jobs:
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -123,7 +140,7 @@ jobs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
|
||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
HELMFILE=src/helm/helmfile.yaml.gotmpl
|
||||
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
||||
helmfile -e $env lint -f $HELMFILE || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
done
|
||||
|
||||
71
.github/workflows/impress-frontend.yml
vendored
71
.github/workflows/impress-frontend.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
test-front:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -80,13 +80,13 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-e2e-chromium
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -119,13 +119,13 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||
@@ -136,3 +136,54 @@ jobs:
|
||||
name: playwright-other-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
bundle-size-check:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@v2
|
||||
with:
|
||||
filters: |
|
||||
lock:
|
||||
- 'src/frontend/**/yarn.lock'
|
||||
app:
|
||||
- 'src/frontend/apps/impress/**'
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Check bundle size changes
|
||||
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||
uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
build-script: "app:build"
|
||||
pattern: "apps/impress/out/**/*.{css,js,html}"
|
||||
exclude: "{**/*.map,**/node_modules/**}"
|
||||
minimum-change-threshold: 500
|
||||
compression: "gzip"
|
||||
cwd: "./src/frontend"
|
||||
show-total: true
|
||||
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
|
||||
omit-unchanged: true
|
||||
install-script: "yarn install --frozen-lockfile"
|
||||
|
||||
34
.github/workflows/impress.yml
vendored
34
.github/workflows/impress.yml
vendored
@@ -25,14 +25,18 @@ jobs:
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
@@ -61,6 +65,26 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lint-spell-mistakes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -70,9 +94,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
@@ -165,9 +190,10 @@ jobs:
|
||||
mc version enable impress/impress-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
27
.github/workflows/label_preview.yml
vendored
Normal file
27
.github/workflows/label_preview.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Label Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
|
||||
|
||||
You can use the existing account with these credentials:
|
||||
- username: `docs`
|
||||
- password: `docs`
|
||||
|
||||
You can also create a new account if you want to.
|
||||
|
||||
Once this Pull Request is merged, the preview will be destroyed.
|
||||
comment-tag: preview-url
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,8 +40,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
@@ -76,3 +75,6 @@ db.sqlite3
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Cursor rules
|
||||
.cursorrules
|
||||
|
||||
447
CHANGELOG.md
447
CHANGELOG.md
@@ -1,5 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
@@ -8,10 +6,374 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve share modal button accessibility #1626
|
||||
|
||||
## [3.10.0] - 2025-11-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(export) enable ODT export for documents #1524
|
||||
- ✨(frontend) improve mobile UX by showing subdocs count #1540
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
|
||||
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
|
||||
- ♻️(frontend) pdf embed use full width #1526
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
|
||||
- ♿(frontend) improve accessibility and styling of summary table #1528
|
||||
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
|
||||
- 🐛(frontend) fix alignment of side menu #1597
|
||||
- 🐛(frontend) fix fallback translations with Trans #1620
|
||||
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
|
||||
- 🐛(export) fix table cell alignment issue in exported documents #1582
|
||||
- 🐛(export) preserve image aspect ratio in PDF export #1622
|
||||
- 🐛(export) Export fails when paste with style #1552
|
||||
|
||||
### Security
|
||||
|
||||
- mitigate role escalation in the ask_for_access viewset #1580
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
|
||||
### 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
|
||||
- ✨ Add comments feature to the editor #1330
|
||||
|
||||
### 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
|
||||
|
||||
- ✨(backend) Comments on text editor #1309
|
||||
- 👷(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
|
||||
|
||||
- 🚸(backend) make document search on title accent-insensitive #874
|
||||
- 🚩 add homepage feature flag #861
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||
- 🌐(i18n) activate chinese and spanish languages #884
|
||||
- 🔧(backend) allow overwriting the data directory #893
|
||||
- ➕(backend) add `django-lasuite` dependency #839
|
||||
- ✨(frontend) advanced table features #908
|
||||
|
||||
## Changed
|
||||
|
||||
- ⚡️(frontend) reduce unblocking time for config #867
|
||||
- ♻️(frontend) bind UI with ability access #900
|
||||
- ♻️(frontend) use built-in Quote block #908
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(nginx) fix 404 when accessing a doc #866
|
||||
- 🔒️(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
|
||||
|
||||
@@ -47,7 +409,6 @@ and this project adheres to
|
||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||
- 🔒️(back) restrict access to document accesses #801
|
||||
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
@@ -66,7 +427,6 @@ and this project adheres to
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
@@ -89,15 +449,14 @@ and this project adheres to
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) SVG export #706
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(action) fix notify-argocd workflow #713
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
@@ -112,7 +471,6 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
@@ -140,7 +498,6 @@ and this project adheres to
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(email) invitation emails in receivers language
|
||||
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
@@ -511,7 +868,7 @@ and this project adheres to
|
||||
- ⚡️(e2e) unique login between tests (#80)
|
||||
- ⚡️(CI) improve e2e job (#86)
|
||||
- ♻️(frontend) improve the error and message info ui (#93)
|
||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
||||
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -529,30 +886,44 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.1.0...main
|
||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
|
||||
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
|
||||
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
|
||||
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||
|
||||
@@ -42,34 +42,38 @@ Examples of unacceptable behavior include:
|
||||
|
||||
## 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:
|
||||
|
||||
1. Correction
|
||||
### 1. Correction
|
||||
|
||||
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.
|
||||
2. Warning
|
||||
|
||||
### 2. Warning
|
||||
|
||||
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.
|
||||
3. Temporary Ban
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
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.
|
||||
|
||||
## Help us with translations
|
||||
|
||||
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
|
||||
|
||||
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
* **title**: A short, descriptive title for the change (*)
|
||||
* **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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 tests: `make test`
|
||||
- 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!
|
||||
|
||||
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
|
||||
|
||||
# ---- 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
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
RUN apk update && apk upgrade --no-cache
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
@@ -30,7 +29,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:24 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -45,7 +44,7 @@ FROM base AS link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
RUN apk add --no-cache \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
@@ -71,7 +70,7 @@ FROM base AS core
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
RUN apk add --no-cache \
|
||||
cairo \
|
||||
file \
|
||||
font-noto \
|
||||
@@ -95,6 +94,14 @@ RUN chmod g=u /etc/passwd
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
|
||||
# when python is upgraded and the path to the certificate changes.
|
||||
# The space between print and the ( is intended otherwise the git lint is failing
|
||||
RUN mkdir /cert && \
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
@@ -117,7 +124,7 @@ FROM core AS backend-development
|
||||
USER root:root
|
||||
|
||||
# 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
|
||||
# dependencies
|
||||
@@ -139,6 +146,9 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
# ---- Production image ----
|
||||
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
|
||||
|
||||
# Gunicorn
|
||||
|
||||
173
Makefile
173
Makefile
@@ -35,10 +35,15 @@ DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
DOCKER_USER := 0:0 # run containers as root on Windows
|
||||
else
|
||||
DOCKER_UID := $(shell id -u)
|
||||
DOCKER_GID := $(shell id -g)
|
||||
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
|
||||
endif
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
@@ -47,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -66,30 +71,111 @@ data/static:
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
create-env-local-files: ## create env.local files in env.d/development
|
||||
create-env-local-files:
|
||||
@touch env.d/development/crowdin.local
|
||||
@touch env.d/development/common.local
|
||||
@touch env.d/development/postgresql.local
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
bootstrap: \
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
create-env-local-files
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
run
|
||||
mails-build
|
||||
.PHONY: post-bootstrap
|
||||
|
||||
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@echo ""
|
||||
@echo "================================================================================"
|
||||
@echo ""
|
||||
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
|
||||
@echo ""
|
||||
@echo " This will set up your development environment with:"
|
||||
@echo " - Docker containers for all services"
|
||||
@echo " - Database migrations and static files"
|
||||
@echo " - Frontend dependencies and build"
|
||||
@echo " - Environment configuration files"
|
||||
@echo ""
|
||||
@echo " Services will be available at:"
|
||||
@echo " - Frontend: http://localhost:3000"
|
||||
@echo " - API: http://localhost:8071"
|
||||
@echo " - Admin: http://localhost:8071/admin"
|
||||
@echo ""
|
||||
@echo "================================================================================"
|
||||
@echo ""
|
||||
@echo "Starting bootstrap process..."
|
||||
else
|
||||
@echo "$(BOLD)"
|
||||
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
@echo "║ ║"
|
||||
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
|
||||
@echo "║ ║"
|
||||
@echo "║ This will set up your development environment with : ║"
|
||||
@echo "║ • Docker containers for all services ║"
|
||||
@echo "║ • Database migrations and static files ║"
|
||||
@echo "║ • Frontend dependencies and build ║"
|
||||
@echo "║ • Environment configuration files ║"
|
||||
@echo "║ ║"
|
||||
@echo "║ Services will be available at: ║"
|
||||
@echo "║ • Frontend: http://localhost:3000 ║"
|
||||
@echo "║ • API: http://localhost:8071 ║"
|
||||
@echo "║ • Admin: http://localhost:8071/admin ║"
|
||||
@echo "║ ║"
|
||||
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
@echo "$(RESET)"
|
||||
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
|
||||
endif
|
||||
@echo ""
|
||||
.PHONY: pre-beautiful-bootstrap
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
build: cache ?= --no-cache
|
||||
build: cache ?=
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend 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: ## build the y-provider container
|
||||
@$(COMPOSE) build y-provider $(cache)
|
||||
@$(COMPOSE) build y-provider-development $(cache)
|
||||
.PHONY: build-yjs-provider
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend $(cache)
|
||||
@$(COMPOSE) build frontend-development $(cache)
|
||||
.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
|
||||
@$(COMPOSE) down
|
||||
@$(COMPOSE_E2E) down
|
||||
.PHONY: down
|
||||
|
||||
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
|
||||
@$(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
|
||||
.PHONY: run-backend
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
@$(COMPOSE) up --force-recreate -d frontend-development
|
||||
.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"
|
||||
@$(COMPOSE) ps
|
||||
@$(COMPOSE_E2E) ps
|
||||
.PHONY: status
|
||||
|
||||
stop: ## stop the development server using Docker
|
||||
@$(COMPOSE) stop
|
||||
@$(COMPOSE_E2E) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Backend
|
||||
@@ -225,20 +326,6 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
@@ -315,10 +402,14 @@ frontend-lint: ## run the frontend linter
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend
|
||||
@$(COMPOSE) stop frontend-development
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-test: ## Run the frontend tests
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn test
|
||||
.PHONY: frontend-test
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
@@ -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/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
.PHONY: bump-packages-version
|
||||
|
||||
111
README.md
111
README.md
@@ -1,13 +1,19 @@
|
||||
<p align="center">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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 align="center">
|
||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||
Chat on Matrix
|
||||
@@ -20,43 +26,61 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
</a>
|
||||
</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"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
⚠️ **Note that Docs provides docs/pdf exporters by loading [two BlockNote packages](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/impress/package.json#L22C7-L23C53), which we use under the AGPL-3.0 licence. Until we comply with the terms of this license, we recommend that you don't run Docs as a commercial product, unless you are willing to sponsor [BlockNote](https://github.com/TypeCellOS/BlockNote).**
|
||||
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
### Write
|
||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
* 😌 Get simple, accessible online editing for your team.
|
||||
* 💅 Create clean documents with beautiful formatting options.
|
||||
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
|
||||
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
|
||||
* 🔌 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
|
||||
* 🤝 Collaborate with your team in real time
|
||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
### Work together
|
||||
* 🤝 Enjoy live editing! See your team collaborate in real time.
|
||||
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
|
||||
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
|
||||
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
#### 🚀 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 🔧
|
||||
|
||||
### 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**
|
||||
|
||||
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
|
||||
$ docker -v
|
||||
@@ -68,7 +92,7 @@ $ docker compose version
|
||||
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**
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
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 🎉
|
||||
|
||||
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
|
||||
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
|
||||
$ make run
|
||||
@@ -117,14 +141,21 @@ To start all the services, except the frontend container, you can use the follow
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
To execute frontend tests & linting only
|
||||
```shellscript
|
||||
$ make frontend-test
|
||||
$ make frontend-lint
|
||||
```
|
||||
|
||||
**Adding content**
|
||||
You can create a basic demo site by running:
|
||||
|
||||
You can create a basic demo site by running this command:
|
||||
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
Finally, you can check all available Make rules using this command:
|
||||
|
||||
```shellscript
|
||||
$ make help
|
||||
@@ -132,7 +163,7 @@ $ make help
|
||||
|
||||
**Django admin**
|
||||
|
||||
You can access the Django admin site at
|
||||
You can access the Django admin site at:
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
|
||||
@@ -144,17 +175,17 @@ $ make superuser
|
||||
|
||||
## 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)
|
||||
|
||||
## Licence 📝
|
||||
## 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 🙌
|
||||
|
||||
@@ -162,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).
|
||||
|
||||
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
|
||||
docs
|
||||
@@ -182,14 +213,14 @@ docs
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,6 +16,15 @@ the following command inside your docker container:
|
||||
|
||||
## [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
|
||||
|
||||
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', resource_deps=['impress-docs-backend-migrate'])
|
||||
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
|
||||
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
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
|
||||
@@ -38,6 +38,10 @@ function _set_user() {
|
||||
# options: docker compose command options
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
# Set DOCKER_USER for Windows compatibility with MinIO
|
||||
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
|
||||
export DOCKER_USER="0:0"
|
||||
fi
|
||||
|
||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
|
||||
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
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "15432:5432"
|
||||
|
||||
@@ -66,7 +67,9 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
@@ -91,47 +94,15 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- 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:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
@@ -141,24 +112,25 @@ services:
|
||||
depends_on:
|
||||
app-dev:
|
||||
condition: service_started
|
||||
y-provider:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend:
|
||||
frontend-development:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
target: impress-dev
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
||||
MEDIA_URL: "http://localhost:8083"
|
||||
PUBLISH_AS_MIT: "false"
|
||||
SW_DEACTIVATED: "true"
|
||||
image: impress:frontend-development
|
||||
volumes:
|
||||
- ./src/frontend:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/impress/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -168,32 +140,35 @@ services:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin
|
||||
- env.d/development/crowdin.local
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
y-provider:
|
||||
y-provider-development:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
|
||||
working_dir: /app/frontend
|
||||
target: y-provider-development
|
||||
image: impress:y-provider-development
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
ports:
|
||||
- "4444:4444"
|
||||
volumes:
|
||||
- ./src/frontend/:/app/frontend
|
||||
- ./src/frontend/:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/servers/y-provider/node_modules
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
@@ -206,24 +181,23 @@ services:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
- env.d/development/kc_postgresql.local
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
|
||||
start_period: 5s
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user.test@chromium.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user.test@webkit.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user.test@firefox.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
@@ -2270,7 +2270,7 @@
|
||||
"cibaInterval": "5",
|
||||
"realmReusableOtpCode": "false"
|
||||
},
|
||||
"keycloakVersion": "20.0.1",
|
||||
"keycloakVersion": "26.3.2",
|
||||
"userManagedAccessAllowed": false,
|
||||
"clientProfiles": {
|
||||
"profiles": []
|
||||
|
||||
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
|
||||
|
||||
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 |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
|
||||
| DJANGO_SECRET_KEY | secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | name of the database | impress |
|
||||
| DB_USER | user to authenticate with | dinum |
|
||||
| DB_PASSWORD | password to authenticate with | pass |
|
||||
| DB_HOST | host of the database | localhost |
|
||||
| DB_PORT | port of the database | 5432 |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| 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 | [] |
|
||||
| USER_OIDC_FIELDS_TO_FULLNAME | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| USER_OIDC_FIELD_TO_SHORTNAME | 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 |
|
||||
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
|
||||
| SW_DEACTIVATED | To not install the service worker | |
|
||||
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||
|
||||
Packages with licences incompatible with the MIT licence:
|
||||
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||
|
||||
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
|
||||
|
||||
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,14 +27,14 @@ backend:
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||
OIDC_RP_CLIENT_ID: impress
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_VERIFY_SSL: False
|
||||
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
|
||||
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
|
||||
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||
@@ -46,9 +46,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: root
|
||||
@@ -82,13 +79,13 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
mountPath: /cert/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:
|
||||
- name: certs
|
||||
configMap:
|
||||
@@ -121,6 +118,22 @@ yProvider:
|
||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_SECRET: my-secret
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
|
||||
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
|
||||
|
||||
# Mount the certificate so yProvider can establish tls with the backend
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/share/ca-certificates/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
@@ -135,9 +148,6 @@ ingress:
|
||||
ingressCollaborationWS:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
||||
|
||||
ingressCollaborationApi:
|
||||
enabled: true
|
||||
@@ -91,7 +91,7 @@ extraDeploy:
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user@chromium.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": "true",
|
||||
@@ -105,7 +105,7 @@ extraDeploy:
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user@webkit.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": "true",
|
||||
@@ -119,7 +119,7 @@ extraDeploy:
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user@firefox.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"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_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||
OIDC_RP_CLIENT_ID: impress
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
@@ -133,7 +133,7 @@ OIDC_RP_SCOPES: "openid email"
|
||||
|
||||
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:
|
||||
|
||||
@@ -146,7 +146,7 @@ keycloak-postgresql-0 1/1 Running 0 26m
|
||||
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:
|
||||
|
||||
@@ -168,12 +168,9 @@ DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
### Find s3 bucket connexion values
|
||||
### 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:
|
||||
|
||||
@@ -191,7 +188,7 @@ redis-master-0 1/1 Running 0 10m
|
||||
|
||||
## 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/
|
||||
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.
|
||||
|
||||
----
|
||||
|
||||
# **Your logo** 📝
|
||||
|
||||
You can add your own logo 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.logo` 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 logo 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
|
||||
|
||||
# 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_SERVER_ORIGIN=http://localhost:3000
|
||||
COLLABORATION_SERVER_SECRET=my-secret
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
|
||||
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True
|
||||
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
@@ -1,6 +1,9 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
# Throttle
|
||||
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
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"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||
"schedule": ["before 7am on monday"],
|
||||
"prCreation": "not-pending",
|
||||
"rebaseWhen": "conflicted",
|
||||
"updateNotScheduled": false,
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
@@ -9,12 +13,24 @@
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": []
|
||||
},
|
||||
{
|
||||
"groupName": "allowed redis versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"eslint",
|
||||
"docx",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"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 treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
|
||||
from . import models
|
||||
|
||||
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
form = movenodeform_factory(models.Document)
|
||||
inlines = (DocumentAccessInline,)
|
||||
list_display = (
|
||||
"id",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""API filters for Impress' core application."""
|
||||
|
||||
import unicodedata
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
@@ -7,13 +9,42 @@ import django_filters
|
||||
from core import models
|
||||
|
||||
|
||||
class DocumentFilter(django_filters.FilterSet):
|
||||
def remove_accents(value):
|
||||
"""Remove accents from a string (vélo -> velo)."""
|
||||
return "".join(
|
||||
c
|
||||
for c in unicodedata.normalize("NFD", value)
|
||||
if unicodedata.category(c) != "Mn"
|
||||
)
|
||||
|
||||
|
||||
class AccentInsensitiveCharFilter(django_filters.CharFilter):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
A custom CharFilter that filters on the accent-insensitive value searched.
|
||||
"""
|
||||
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
def filter(self, qs, value):
|
||||
"""
|
||||
Apply the filter to the queryset using the unaccented version of the field.
|
||||
|
||||
Args:
|
||||
qs: The queryset to filter.
|
||||
value: The value to search for in the unaccented field.
|
||||
Returns:
|
||||
A filtered queryset.
|
||||
"""
|
||||
if value:
|
||||
value = remove_accents(value)
|
||||
return super().filter(qs, value)
|
||||
|
||||
|
||||
class DocumentFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for filtering documents on title (accent and case insensitive).
|
||||
"""
|
||||
|
||||
title = AccentInsensitiveCharFilter(
|
||||
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -29,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_masked = django_filters.BooleanFilter(
|
||||
method="filter_is_masked", label=_("Masked")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
@@ -75,3 +109,30 @@ class ListDocumentFilter(DocumentFilter):
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are masked by the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_masked=true
|
||||
→ Filters documents marked as masked by the logged-in user
|
||||
- /api/v1.0/documents/?is_masked=false
|
||||
→ Filters documents not marked as masked by the logged-in user
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset_method = queryset.filter if bool(value) else queryset.exclude
|
||||
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
|
||||
|
||||
|
||||
class UserSearchFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for searching users.
|
||||
"""
|
||||
|
||||
q = django_filters.CharFilter(min_length=5, max_length=254)
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core import choices
|
||||
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
||||
).exists()
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||
"""A permission class for templates and invitations."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""check create permission for templates."""
|
||||
return request.user.is_authenticated or view.action != "create"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
action = view.action
|
||||
try:
|
||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||
except KeyError:
|
||||
pass
|
||||
return abilities.get(action, False)
|
||||
|
||||
|
||||
class DocumentAccessPermission(AccessPermission):
|
||||
class DocumentPermission(permissions.BasePermission):
|
||||
"""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):
|
||||
"""
|
||||
Return a 404 on deleted documents
|
||||
@@ -127,10 +129,61 @@ class DocumentAccessPermission(AccessPermission):
|
||||
) and deleted_at < get_trashbin_cutoff():
|
||||
raise Http404
|
||||
|
||||
# Compute permission first to ensure the "user_roles" attribute is set
|
||||
has_permission = super().has_object_permission(request, view, obj)
|
||||
abilities = obj.get_abilities(request.user)
|
||||
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:
|
||||
raise Http404
|
||||
|
||||
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."""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import binascii
|
||||
import mimetypes
|
||||
@@ -7,12 +8,13 @@ from base64 import b64decode
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import magic
|
||||
from rest_framework import 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.converter_services import (
|
||||
ConversionError,
|
||||
@@ -23,143 +25,63 @@ from core.services.converter_services import (
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
full_name = serializers.SerializerMethodField(read_only=True)
|
||||
short_name = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
if not instance.full_name:
|
||||
email = instance.email.split("@")[0]
|
||||
return slugify(email)
|
||||
|
||||
return instance.full_name
|
||||
|
||||
def get_short_name(self, instance):
|
||||
"""Return the short name of the user."""
|
||||
if not instance.short_name:
|
||||
email = instance.email.split("@")[0]
|
||||
return slugify(email)
|
||||
|
||||
return instance.short_name
|
||||
|
||||
|
||||
class UserLightSerializer(UserSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
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:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["full_name", "short_name"]
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
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:
|
||||
model = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
fields = ["id", "user", "team", "role", "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):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
@@ -167,16 +89,22 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses_ancestors = 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)
|
||||
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -188,13 +116,18 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"path",
|
||||
"title",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
"user_role",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -205,49 +138,70 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"numchild",
|
||||
"path",
|
||||
"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."""
|
||||
request = self.context.get("request")
|
||||
if not request:
|
||||
return {}
|
||||
|
||||
if request:
|
||||
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 instance.get_abilities(request.user)
|
||||
|
||||
return {}
|
||||
|
||||
def get_user_roles(self, document):
|
||||
def get_user_role(self, instance):
|
||||
"""
|
||||
Return roles of the logged-in user for the current document,
|
||||
taking into account ancestors.
|
||||
"""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_roles(request.user)
|
||||
return []
|
||||
return instance.get_role(request.user) if request else None
|
||||
|
||||
def get_deleted_at(self, instance):
|
||||
"""Return the deleted_at of the current document."""
|
||||
return instance.ancestors_deleted_at
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["id", "path", "depth"]
|
||||
read_only_fields = ["id", "path", "depth"]
|
||||
|
||||
|
||||
class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
@@ -259,13 +213,19 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"path",
|
||||
"title",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
"user_role",
|
||||
"websocket",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
@@ -275,7 +235,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
"user_role",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
@@ -361,6 +321,99 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
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):
|
||||
"""
|
||||
Serializer for creating a document from a server-to-server request.
|
||||
@@ -379,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
content = serializers.CharField(required=True)
|
||||
# User
|
||||
sub = serializers.CharField(
|
||||
required=True, validators=[models.User.sub_validator], max_length=255
|
||||
required=True, validators=[validators.sub_validator], max_length=255
|
||||
)
|
||||
email = serializers.EmailField(required=True)
|
||||
language = serializers.ChoiceField(
|
||||
@@ -408,9 +461,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
language = user.language or language
|
||||
|
||||
try:
|
||||
document_content = YdocConverter().convert_markdown(
|
||||
validated_data["content"]
|
||||
)
|
||||
document_content = YdocConverter().convert(validated_data["content"])
|
||||
except ConversionError as err:
|
||||
raise serializers.ValidationError(
|
||||
{"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.
|
||||
"""
|
||||
|
||||
link_reach = serializers.ChoiceField(
|
||||
choices=models.LinkReachChoices.choices, required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
@@ -472,6 +527,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"link_reach",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that link_role and link_reach are compatible using get_select_options."""
|
||||
link_reach = attrs.get("link_reach")
|
||||
link_role = attrs.get("link_role")
|
||||
|
||||
if not link_reach:
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": _("This field is required.")}
|
||||
)
|
||||
|
||||
# Get available options based on ancestors' link definition
|
||||
available_options = models.LinkReachChoices.get_select_options(
|
||||
**self.instance.ancestors_link_definition
|
||||
)
|
||||
|
||||
# Validate link_reach is allowed
|
||||
if link_reach not in available_options:
|
||||
msg = _(
|
||||
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": msg % {"link_reach": link_reach}}
|
||||
)
|
||||
|
||||
# Validate link_role is compatible with link_reach
|
||||
allowed_roles = available_options[link_reach]
|
||||
|
||||
# Restricted reach: link_role must be None
|
||||
if link_reach == models.LinkReachChoices.RESTRICTED:
|
||||
if link_role is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
"Cannot set link_role when link_reach is 'restricted'. "
|
||||
"Link role must be null for restricted reach."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
# Non-restricted: link_role must be in allowed roles
|
||||
if link_role not in allowed_roles:
|
||||
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
|
||||
f"Allowed roles: {allowed_roles_str}"
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -517,16 +624,17 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
mime = magic.Magic(mime=True)
|
||||
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||
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"] = (
|
||||
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||
)
|
||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||
|
||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||
|
||||
# Try guessing a coherent extension from the mimetype
|
||||
if extension_mime_type != magic_mime_type:
|
||||
self.context["is_unsafe"] = True
|
||||
# Try guessing a coherent extension from the mimetype
|
||||
if extension_mime_type != magic_mime_type:
|
||||
self.context["is_unsafe"] = True
|
||||
|
||||
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
||||
# 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:
|
||||
attrs["issuer"] = user
|
||||
|
||||
if attrs.get("email"):
|
||||
attrs["email"] = attrs["email"].lower()
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_role(self, role):
|
||||
@@ -664,6 +775,52 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
"""Validate version filters applied to the list endpoint."""
|
||||
|
||||
@@ -735,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
choices=enums.MoveNodePositionChoices.choices,
|
||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
)
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
"""Serialize reactions."""
|
||||
|
||||
users = UserLightSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
fields = [
|
||||
"id",
|
||||
"emoji",
|
||||
"created_at",
|
||||
"users",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "users"]
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize comments (nested under a thread) with reactions and abilities."""
|
||||
|
||||
user = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField()
|
||||
reactions = ReactionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"body",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reactions",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reactions",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate comment data."""
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
attrs["thread_id"] = self.context["thread_id"]
|
||||
attrs["user_id"] = user.id if user else None
|
||||
return attrs
|
||||
|
||||
def get_abilities(self, obj):
|
||||
"""Return comment's abilities."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return obj.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class ThreadSerializer(serializers.ModelSerializer):
|
||||
"""Serialize threads in a backward compatible shape for current frontend.
|
||||
|
||||
We expose a flatten representation where ``content`` maps to the first
|
||||
comment's body. Creating a thread requires a ``content`` field which is
|
||||
stored as the first comment.
|
||||
"""
|
||||
|
||||
creator = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
body = serializers.JSONField(write_only=True, required=True)
|
||||
comments = serializers.SerializerMethodField(read_only=True)
|
||||
comments = CommentSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Thread
|
||||
fields = [
|
||||
"id",
|
||||
"body",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"abilities",
|
||||
"comments",
|
||||
"resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"metadata",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"abilities",
|
||||
"comments",
|
||||
"resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"metadata",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate thread data."""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
attrs["document_id"] = self.context["resource_id"]
|
||||
attrs["creator_id"] = user.id if user else None
|
||||
|
||||
return attrs
|
||||
|
||||
def get_abilities(self, thread):
|
||||
"""Return thread's abilities."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return thread.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
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
@@ -1,130 +1,59 @@
|
||||
"""Authentication Backends for the Impress core app."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import DuplicateEmailError, User
|
||||
from core.models import DuplicateEmailError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Settings renamed warnings
|
||||
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
|
||||
logger.warning(
|
||||
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed to "
|
||||
"OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
|
||||
)
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
|
||||
logger.warning(
|
||||
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed to "
|
||||
"OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
|
||||
try:
|
||||
userinfo = user_response.json()
|
||||
except ValueError:
|
||||
try:
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
except Exception as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Invalid response format or token verification failed")
|
||||
) from e
|
||||
|
||||
return userinfo
|
||||
|
||||
def verify_claims(self, claims):
|
||||
"""
|
||||
Verify the presence of essential claims and the "sub" (which is mandatory as defined
|
||||
by the OIDC specification) to decide if authentication should be allowed.
|
||||
"""
|
||||
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
|
||||
missing_claims = [claim for claim in essential_claims if claim not in claims]
|
||||
|
||||
if missing_claims:
|
||||
logger.error("Missing essential claims: %s", missing_claims)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found."""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
if not self.verify_claims(user_info):
|
||||
raise SuspiciousOperation("Claims verification failed.")
|
||||
|
||||
sub = user_info["sub"]
|
||||
email = user_info.get("email")
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"short_name": short_name,
|
||||
return {
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
try:
|
||||
user = User.objects.get_user_by_sub_or_email(sub, email)
|
||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
value and value != getattr(user, key) for key, value in claims.items()
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
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
|
||||
from enum import StrEnum
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
@@ -38,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices):
|
||||
LAST_SIBLING = "last-sibling", _("Last sibling")
|
||||
LEFT = "left", _("Left")
|
||||
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
|
||||
"""
|
||||
@@ -35,6 +34,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
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}")
|
||||
email = factory.Faker("email")
|
||||
@@ -149,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""Add link traces to document from a given list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
models.LinkTrace.objects.update_or_create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def favorited_by(self, create, extracted, **kwargs):
|
||||
@@ -158,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
for item in extracted:
|
||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def masked_by(self, create, extracted, **kwargs):
|
||||
"""Mark document as masked by a list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.update_or_create(
|
||||
document=self, user=item, defaults={"is_masked": True}
|
||||
)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
@@ -181,6 +191,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
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):
|
||||
"""A factory to create templates"""
|
||||
|
||||
@@ -235,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class ThreadFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create threads for a document"""
|
||||
|
||||
class Meta:
|
||||
model = models.Thread
|
||||
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class CommentFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create comments for a thread"""
|
||||
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
|
||||
thread = factory.SubFactory(ThreadFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
body = factory.Faker("text")
|
||||
|
||||
|
||||
class ReactionFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create reactions for a comment"""
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
|
||||
comment = factory.SubFactory(CommentFactory)
|
||||
emoji = "test"
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to reaction from a given list of users or create one if not provided."""
|
||||
if not create:
|
||||
return
|
||||
|
||||
if not extracted:
|
||||
# the factory is being created, but no users were provided
|
||||
user = UserFactory()
|
||||
self.users.add(user)
|
||||
return
|
||||
|
||||
# Add the iterable of groups using bulk addition
|
||||
self.users.add(*extracted)
|
||||
|
||||
52
src/backend/core/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(
|
||||
model_name="documentaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
condition=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddConstraint(
|
||||
model_name="templateaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
condition=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.contrib.postgres.operations import UnaccentExtension
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"),
|
||||
]
|
||||
|
||||
operations = [UnaccentExtension()]
|
||||
@@ -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 = {
|
||||
"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. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
@@ -44,7 +45,7 @@ AI_ACTIONS = {
|
||||
}
|
||||
|
||||
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}. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
|
||||
@@ -17,7 +17,7 @@ class CollaborationService:
|
||||
def reset_connections(self, room, user_id=None):
|
||||
"""
|
||||
Reset connections of a room in the collaboration server.
|
||||
Reseting a connection means that the user will be disconnected and will
|
||||
Resetting a connection means that the user will be disconnected and will
|
||||
have to reconnect to the collaboration server, with updated rights.
|
||||
"""
|
||||
endpoint = "reset-connections"
|
||||
@@ -41,3 +41,35 @@ class CollaborationService:
|
||||
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||
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
|
||||
|
||||
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
|
||||
"""Raised when the conversion service is unavailable."""
|
||||
|
||||
|
||||
class InvalidResponseError(ConversionError):
|
||||
"""Raised when the conversion service returns an invalid response."""
|
||||
|
||||
|
||||
class MissingContentError(ConversionError):
|
||||
"""Raised when the response is missing required content."""
|
||||
|
||||
|
||||
class YdocConverter:
|
||||
"""Service class for conversion-related operations."""
|
||||
|
||||
@@ -32,47 +26,47 @@ class YdocConverter:
|
||||
def auth_header(self):
|
||||
"""Build microservice authentication header."""
|
||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||
return settings.Y_PROVIDER_API_KEY
|
||||
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."""
|
||||
|
||||
if not text:
|
||||
raise ValidationError("Input text cannot be empty")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
response = self._request(
|
||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||
json={
|
||||
"content": text,
|
||||
},
|
||||
headers={
|
||||
"Authorization": self.auth_header,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
text,
|
||||
content_type,
|
||||
accept,
|
||||
)
|
||||
response.raise_for_status()
|
||||
conversion_response = response.json()
|
||||
|
||||
if accept == "application/vnd.yjs.doc":
|
||||
return b64encode(response.content).decode("utf-8")
|
||||
if accept in {"text/markdown", "text/html"}:
|
||||
return response.text
|
||||
if accept == "application/json":
|
||||
return response.json()
|
||||
raise ValidationError("Unsupported format")
|
||||
except requests.RequestException as err:
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to conversion service",
|
||||
) 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,
|
||||
)
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import random
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.fernet import Fernet
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -288,7 +288,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
assert user.email is None
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -345,11 +345,15 @@ def test_authentication_get_userinfo_json_response():
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch):
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
@@ -371,21 +375,25 @@ def test_authentication_get_userinfo_token_response(monkeypatch):
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response():
|
||||
def test_authentication_get_userinfo_invalid_response(settings):
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Invalid response format or token verification failed",
|
||||
match="User info response was not valid JWT",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
@@ -450,100 +458,54 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
# Essential claims
|
||||
|
||||
|
||||
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
|
||||
"""The sub claim should be mandatory by default."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"test": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
KeyError,
|
||||
match="sub",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"essential_claims, missing_claims",
|
||||
[
|
||||
(["email", "sub"], ["email"]),
|
||||
(["Email", "sub"], ["Email"]), # Case sensitivity
|
||||
],
|
||||
)
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_authentication_verify_claims_essential_missing(
|
||||
mock_logger,
|
||||
essential_claims,
|
||||
missing_claims,
|
||||
django_assert_num_queries,
|
||||
monkeypatch,
|
||||
@responses.activate
|
||||
def test_authentication_session_tokens(
|
||||
django_assert_num_queries, monkeypatch, rf, settings
|
||||
):
|
||||
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
|
||||
"""
|
||||
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
|
||||
"""
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
|
||||
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
|
||||
settings.OIDC_STORE_ACCESS_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
|
||||
request.session = {}
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
def verify_token_mocked(*args, **kwargs):
|
||||
return {"sub": "123", "email": "test@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Claims verification failed",
|
||||
),
|
||||
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||
json={
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
|
||||
|
||||
|
||||
@override_settings(
|
||||
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
|
||||
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
|
||||
)
|
||||
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
|
||||
"""Ensure user is authenticated when all essential claims are present."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"email": "john.doe@example.com",
|
||||
"last_name": "Doe",
|
||||
"sub": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(settings.OIDC_OP_USER_ENDPOINT),
|
||||
json={"sub": "123", "email": "test@example.com"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
with django_assert_num_queries(6):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
user = klass.authenticate(
|
||||
request,
|
||||
code="test-code",
|
||||
nonce="test-nonce",
|
||||
code_verifier="test-code-verifier",
|
||||
)
|
||||
|
||||
assert models.User.objects.filter(id=user.id).exists()
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.full_name == "Doe"
|
||||
assert user.short_name is None
|
||||
assert user.email == "john.doe@example.com"
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
@@ -1,14 +1,16 @@
|
||||
"""
|
||||
Test document accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core import choices, factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||
@@ -51,12 +53,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_api_document_accesses_list_unexisting_document():
|
||||
@@ -69,39 +66,46 @@ def test_api_document_accesses_list_unexisting_document():
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@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(
|
||||
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
|
||||
to which they are directly related, whatever their role in the document.
|
||||
Authenticated users with no privileged role should only be able to list document
|
||||
accesses associated with privileged roles for a document, including from ancestors.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
owner = factories.UserFactory()
|
||||
accesses = []
|
||||
|
||||
document_access = factories.UserDocumentAccessFactory(
|
||||
user=owner, role=models.RoleChoices.OWNER
|
||||
# Create documents structured as a tree
|
||||
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||
# make all documents below the grand parent readable without a specific access for the user
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=unreadable_ancestor, link_reach="authenticated"
|
||||
)
|
||||
accesses.append(document_access)
|
||||
document = document_access.document
|
||||
parent = factories.DocumentFactory(parent=grand_parent)
|
||||
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:
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
@@ -116,33 +120,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
||||
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
|
||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
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
|
||||
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),
|
||||
"document": {
|
||||
"id": str(access.document_id),
|
||||
"path": access.document.path,
|
||||
"depth": access.document.depth,
|
||||
},
|
||||
"user": {
|
||||
"id": None,
|
||||
"email": None,
|
||||
"full_name": access.user.full_name,
|
||||
"short_name": access.user.short_name,
|
||||
}
|
||||
@@ -150,40 +153,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
||||
else None,
|
||||
"team": access.team,
|
||||
"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"],
|
||||
)
|
||||
|
||||
for access in content["results"]:
|
||||
assert access["role"] in models.PRIVILEGED_ROLES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
|
||||
def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
||||
via, role, mock_user_teams
|
||||
@pytest.mark.parametrize(
|
||||
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
|
||||
)
|
||||
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
|
||||
to which they are directly related, whatever their role in the document.
|
||||
Authenticated users with a privileged role should be able to list all
|
||||
document accesses whatever the role, including from ancestors.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
owner = factories.UserFactory()
|
||||
accesses = []
|
||||
|
||||
document_access = factories.UserDocumentAccessFactory(
|
||||
user=owner, role=models.RoleChoices.OWNER
|
||||
# Create documents structured as a tree
|
||||
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||
# make all documents below the grand parent readable without a specific access for the user
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=unreadable_ancestor, link_reach="authenticated"
|
||||
)
|
||||
accesses.append(document_access)
|
||||
document = document_access.document
|
||||
user_access = None
|
||||
parent = factories.DocumentFactory(parent=grand_parent)
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
if via == USER:
|
||||
user_access = models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
@@ -197,61 +207,322 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
||||
team="lasuite",
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError()
|
||||
|
||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
||||
accesses.append(access1)
|
||||
accesses.append(access2)
|
||||
# Create accesses related to each document
|
||||
ancestors_accesses = [
|
||||
# Access on unreadable ancestor should still be listed
|
||||
# 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
|
||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||
|
||||
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
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 4
|
||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
||||
assert len(content) == 7
|
||||
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": base_user if via == "user" else None,
|
||||
"team": "lasuite" if via == "team" else "",
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": None,
|
||||
"team": access1.team,
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": access2_user,
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(document_access.id),
|
||||
"user": serializers.UserSerializer(instance=owner).data,
|
||||
"team": "",
|
||||
"role": models.RoleChoices.OWNER,
|
||||
"abilities": document_access.get_abilities(user),
|
||||
},
|
||||
"id": str(access.id),
|
||||
"document": {
|
||||
"id": str(access.document_id),
|
||||
"path": access.document.path,
|
||||
"depth": access.document.depth,
|
||||
},
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"language": access.user.language,
|
||||
"full_name": access.user.full_name,
|
||||
"short_name": access.user.short_name,
|
||||
}
|
||||
if access.user
|
||||
else None,
|
||||
"max_ancestors_role": None,
|
||||
"max_role": access.role,
|
||||
"team": access.team,
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
for access in ancestors_accesses + document_accesses
|
||||
],
|
||||
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():
|
||||
"""
|
||||
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("role", models.RoleChoices)
|
||||
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
|
||||
@@ -333,7 +606,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
|
||||
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
|
||||
else:
|
||||
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.json() == {
|
||||
"id": str(access.id),
|
||||
"document": {
|
||||
"id": str(access.document_id),
|
||||
"path": access.document.path,
|
||||
"depth": access.document.depth,
|
||||
},
|
||||
"user": access_user,
|
||||
"team": "",
|
||||
"role": access.role,
|
||||
"max_ancestors_role": None,
|
||||
"max_role": access.role,
|
||||
"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("create_for", VIA)
|
||||
def test_api_document_accesses_update_administrator_except_owner(
|
||||
create_for,
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
@@ -481,32 +763,31 @@ def test_api_document_accesses_update_administrator_except_owner(
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"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():
|
||||
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(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
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()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
if field in ["role", "max_role"]:
|
||||
assert updated_values == {
|
||||
**old_values,
|
||||
"role": new_values["role"],
|
||||
"max_role": new_values["role"],
|
||||
}
|
||||
else:
|
||||
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():
|
||||
new_data = {**old_values, field: value}
|
||||
# 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(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
@@ -624,7 +905,9 @@ def test_api_document_accesses_update_administrator_to_owner(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("create_for", VIA)
|
||||
def test_api_document_accesses_update_owner(
|
||||
create_for,
|
||||
via,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
@@ -655,42 +938,39 @@ def test_api_document_accesses_update_owner(
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"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():
|
||||
new_data = {**old_values, field: value}
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
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 == 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()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
if field in ["role", "max_role"]:
|
||||
assert updated_values == {
|
||||
**old_values,
|
||||
"role": new_values["role"],
|
||||
"max_role": new_values["role"],
|
||||
}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner_self(
|
||||
def test_api_document_accesses_update_owner_self_root(
|
||||
via,
|
||||
mock_user_teams,
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@@ -931,17 +1256,16 @@ def test_api_document_accesses_delete_owners(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
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)
|
||||
|
||||
client = APIClient()
|
||||
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 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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||
@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
|
||||
except for the "owner" 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.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
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:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
document=documents[0], user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
document=documents[0], team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory(language="en-us")
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
document = documents[-1]
|
||||
response = client.post(
|
||||
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.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
|
||||
@@ -165,9 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
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),
|
||||
"team": "",
|
||||
"max_ancestors_role": None,
|
||||
"max_role": role,
|
||||
"role": role,
|
||||
"team": "",
|
||||
"user": other_user,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||
@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.
|
||||
"""
|
||||
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()
|
||||
|
||||
client = APIClient()
|
||||
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:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=documents[0], user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
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
|
||||
@@ -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()
|
||||
other_user = serializers.UserSerializer(instance=other_user).data
|
||||
assert response.json() == {
|
||||
"id": str(new_document_access.id),
|
||||
"user": other_user,
|
||||
"team": "",
|
||||
"role": role,
|
||||
"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
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
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()
|
||||
other_user_data = serializers.UserSerializer(instance=other_user).data
|
||||
assert response.json() == {
|
||||
"id": str(new_document_access.id),
|
||||
"user": other_user_data,
|
||||
"team": "",
|
||||
"role": role,
|
||||
"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
|
||||
email = mail.outbox[index]
|
||||
@@ -304,7 +486,7 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
|
||||
)
|
||||
elif expected_language == "fr-fr":
|
||||
assert (
|
||||
f"{user.full_name} a partagé un document avec vous: {document.title}".lower()
|
||||
f"{user.full_name} a partagé un document avec vous : {document.title}".lower()
|
||||
in email_subject.lower()
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content.lower()
|
||||
|
||||
@@ -575,7 +575,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
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 = {
|
||||
"email": existing_user.email,
|
||||
"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
|
||||
|
||||
|
||||
@@ -743,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
|
||||
assert value == old_invitation_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_document_invitations_patch(via, role, mock_user_teams):
|
||||
"""Partially updating an invitation should be allowed."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory(role="editor")
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role=role
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
|
||||
{"role": "reader"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.role == "reader"
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
@@ -824,3 +881,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations_throttling(settings):
|
||||
"""Test api document ask for access throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
factories.InvitationFactory(document=document, issuer=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _i in range(2):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope invitation", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI transform
|
||||
Authenticated who are not related to a document should be able to request AI transform
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
"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. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
"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. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
|
||||
@@ -99,7 +99,7 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
{
|
||||
"role": "system",
|
||||
"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. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"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")
|
||||
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI translate
|
||||
Authenticated who are not related to a document should be able to request AI translate
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -197,7 +197,7 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
||||
{
|
||||
"role": "system",
|
||||
"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 Colombian Spanish. "
|
||||
"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",
|
||||
"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 Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user