mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
682 Commits
feature/ai
...
hack2025/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb7aeb7ec | ||
|
|
23860065e1 | ||
|
|
f459c56121 | ||
|
|
4a81e1526e | ||
|
|
abcd61cf2f | ||
|
|
c1a591fb4f | ||
|
|
83d8478b5d | ||
|
|
6bd136c76e | ||
|
|
e929fcc682 | ||
|
|
fa819bc1ff | ||
|
|
43e529da2a | ||
|
|
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 | ||
|
|
ecd06560c6 | ||
|
|
e9ab099ce0 | ||
|
|
67b69d05f7 | ||
|
|
f429eb053a | ||
|
|
ad11b7f554 | ||
|
|
3d5adad227 | ||
|
|
de8e812f2f | ||
|
|
7a1601c682 | ||
|
|
0537572542 | ||
|
|
8aab007ad1 | ||
|
|
cde3de43f7 | ||
|
|
8c0c3c2f44 | ||
|
|
c11d59c434 | ||
|
|
8836109945 | ||
|
|
ba136ff82f | ||
|
|
96d9d1a184 | ||
|
|
771ffdc7cc | ||
|
|
82eba1e8ea | ||
|
|
8c42599d0f | ||
|
|
8620cf4857 | ||
|
|
2a7da73248 | ||
|
|
e8e9922832 | ||
|
|
2da4ce4570 | ||
|
|
50b90f9ae7 | ||
|
|
65ddf7fbe8 | ||
|
|
d3a7ee74b3 | ||
|
|
65e450c6cc | ||
|
|
725cae5470 | ||
|
|
3881930e82 | ||
|
|
910686293c | ||
|
|
7e7c9ac4c5 | ||
|
|
d5d2cfab8e | ||
|
|
f2ed8e0ea1 | ||
|
|
fbe8a26dba | ||
|
|
3e974be9f4 | ||
|
|
10f9d25920 | ||
|
|
4178693e63 | ||
|
|
53be6de5f8 | ||
|
|
4ff90abdee | ||
|
|
544dd00c16 | ||
|
|
a3cd4c51ea | ||
|
|
7e1eed3abd | ||
|
|
8bee476b5b | ||
|
|
e86919fb9a | ||
|
|
a5b9169eb6 | ||
|
|
c0dfb4b6b3 | ||
|
|
be051ad7d2 | ||
|
|
a4452784e1 | ||
|
|
2929e98260 | ||
|
|
a1914c6259 | ||
|
|
c882f1386c | ||
|
|
c02f19a2cd | ||
|
|
34a208a80d | ||
|
|
6976bb7c78 | ||
|
|
621393165f | ||
|
|
3e9b530985 | ||
|
|
54f9b3963e | ||
|
|
710bbf512c | ||
|
|
747ca70186 | ||
|
|
9374495fda | ||
|
|
ef7cc67387 | ||
|
|
a8529e434a | ||
|
|
f8203a1766 | ||
|
|
ce8b98e256 | ||
|
|
4243519eee | ||
|
|
1abf529891 | ||
|
|
69ca4af539 | ||
|
|
14b2adedfb | ||
|
|
a7edb382a7 | ||
|
|
fb5400c26b | ||
|
|
8473facbee | ||
|
|
5db446e8a8 | ||
|
|
34dfb3fd66 | ||
|
|
f9a91eda2d | ||
|
|
eba926dea4 | ||
|
|
3839a2e8b1 | ||
|
|
a88d62e07d | ||
|
|
b61a7a4961 | ||
|
|
20d32ecc4e | ||
|
|
313acf4f78 | ||
|
|
3a6105cc7e | ||
|
|
bbe17156be | ||
|
|
51cc26b916 | ||
|
|
cab8ef51df | ||
|
|
6627518017 | ||
|
|
12c18bc4e9 | ||
|
|
aff330eb5b | ||
|
|
bcdaedba9b | ||
|
|
799814e3e3 | ||
|
|
02c9b2ea2e | ||
|
|
eb23aefd55 | ||
|
|
0c49019490 | ||
|
|
170dbe07bb | ||
|
|
70136f2415 | ||
|
|
2a8fc97f2f | ||
|
|
9570701bc3 | ||
|
|
4b28b3c23b | ||
|
|
f26fc43df0 | ||
|
|
05a6818439 | ||
|
|
8056fd7d66 | ||
|
|
c85224af42 | ||
|
|
70f1b6a8e8 | ||
|
|
0f07fdcb65 | ||
|
|
2e13dfb9bc | ||
|
|
a026435eb7 | ||
|
|
7007d56c38 | ||
|
|
0405e6a3f6 | ||
|
|
cb8bd4b937 | ||
|
|
4316b4e67d | ||
|
|
534085439f | ||
|
|
da02d3d756 | ||
|
|
87960d3773 | ||
|
|
e0af6d36e1 | ||
|
|
cbf9091d1c | ||
|
|
9176328200 | ||
|
|
6efc2377fe | ||
|
|
1c02b0ad8e | ||
|
|
007854a877 | ||
|
|
57cead448d | ||
|
|
f20d256cd1 | ||
|
|
76c01df3ae | ||
|
|
20315e9b60 | ||
|
|
2203d49a52 | ||
|
|
56aa69f56a | ||
|
|
0aabf26694 | ||
|
|
fcf8b38021 | ||
|
|
757d7f35cd | ||
|
|
fdc49dc002 | ||
|
|
197ba47f73 | ||
|
|
d5997ba9d5 | ||
|
|
1c6d18fdf3 | ||
|
|
24d126f410 | ||
|
|
a5e1751cf3 | ||
|
|
0cabb655ad | ||
|
|
38eb6d45b7 | ||
|
|
5bb7ad643a | ||
|
|
57b8881fc6 | ||
|
|
89ad610ba6 | ||
|
|
251787b835 | ||
|
|
f95173e096 | ||
|
|
a7944cce80 | ||
|
|
7941fc91d5 | ||
|
|
7fc83a4fcd | ||
|
|
2bf47b7705 | ||
|
|
23b0214a2a | ||
|
|
f244509de3 | ||
|
|
fda5f8f008 | ||
|
|
9a79b09b07 | ||
|
|
b24acd14e2 | ||
|
|
1531846115 | ||
|
|
ebf6d46e37 | ||
|
|
b9b5f86cf4 | ||
|
|
56412b0be5 | ||
|
|
af052cd06b | ||
|
|
8927635c5f | ||
|
|
76bce4313b | ||
|
|
5ac71bfac1 | ||
|
|
cb4e148afc | ||
|
|
2d24825be0 | ||
|
|
7b1ddc0e05 | ||
|
|
22a665e535 | ||
|
|
a22bf95bce | ||
|
|
3ce1826355 | ||
|
|
d099d58f77 | ||
|
|
ebd49f05a8 | ||
|
|
315c2c2c43 | ||
|
|
e442908c50 | ||
|
|
6672292d93 | ||
|
|
7dda74421f | ||
|
|
9c25b684e3 | ||
|
|
cd5ee3fb7c | ||
|
|
942c0f059c | ||
|
|
3acee1e6fa | ||
|
|
26ea32bd0b | ||
|
|
7f6ffa0123 | ||
|
|
ef2127585c | ||
|
|
54a75bc338 | ||
|
|
50d098c777 | ||
|
|
757c09b189 | ||
|
|
30c5cfab62 | ||
|
|
f069329e18 | ||
|
|
ef8ee67553 | ||
|
|
ad47fc2d60 | ||
|
|
009f5d6ed4 | ||
|
|
64d0072c8d | ||
|
|
aefbc2e0b9 | ||
|
|
15dc1e3012 | ||
|
|
6cc20aeacb | ||
|
|
7da7214afb | ||
|
|
c369419512 | ||
|
|
d9ad397c94 | ||
|
|
3191d890f3 | ||
|
|
68f3387539 | ||
|
|
0dc8b4556c | ||
|
|
e123e91959 | ||
|
|
2709400773 | ||
|
|
8281c6159b | ||
|
|
296dbb7957 | ||
|
|
3827f0f799 | ||
|
|
d89e3dc6d4 | ||
|
|
91cf5f9367 | ||
|
|
5cc4b07cf6 | ||
|
|
0cfc242e09 | ||
|
|
a6b3cfdb0c | ||
|
|
5ead18c94c | ||
|
|
5eeb8cae5c | ||
|
|
68bf024005 | ||
|
|
fdd1068c90 | ||
|
|
ba695bf647 | ||
|
|
27e7aec193 | ||
|
|
58b712a1de | ||
|
|
08f9036523 | ||
|
|
ebe3efc8f7 | ||
|
|
66fbf27913 | ||
|
|
20e4a4e42a | ||
|
|
1aa4844eeb | ||
|
|
4bb9c092cb | ||
|
|
c493eb8924 | ||
|
|
40fdf97520 | ||
|
|
91b10e75dd | ||
|
|
7a6da10e1c | ||
|
|
004e8ec645 | ||
|
|
a1bca9c436 | ||
|
|
d02fa1ddd4 | ||
|
|
1fd66d3081 | ||
|
|
bae8c4c563 | ||
|
|
0dae35dab1 | ||
|
|
929a50b573 | ||
|
|
83dfd26d1c | ||
|
|
addc6a331f | ||
|
|
5c5763a0ef | ||
|
|
5042f4ca47 | ||
|
|
8c247c8777 | ||
|
|
239342fbbd | ||
|
|
8ccfdb3c6a | ||
|
|
4de03d292a | ||
|
|
2e8a399668 | ||
|
|
7b39b3f7f6 | ||
|
|
0003f9d0de | ||
|
|
8117866ce7 | ||
|
|
1d0386d9b5 | ||
|
|
7ff4bc457f | ||
|
|
4333b46901 | ||
|
|
d073a9c9b3 | ||
|
|
48662ceecb | ||
|
|
9a12452c26 | ||
|
|
276b4f7c1b | ||
|
|
0189078917 | ||
|
|
6569f61fc4 | ||
|
|
7880391648 | ||
|
|
9b95a9c551 | ||
|
|
3b151cf580 | ||
|
|
8b0f4db650 | ||
|
|
a39990d90f | ||
|
|
609ff91894 | ||
|
|
265a24fe7e | ||
|
|
9bc2b4877f | ||
|
|
b93b43abe8 | ||
|
|
dd8bb18f69 | ||
|
|
545e8b2a3c | ||
|
|
81837aff2b | ||
|
|
40c1107959 | ||
|
|
0d7d42254b | ||
|
|
67dc7feb98 | ||
|
|
5b4b100e90 | ||
|
|
b8be010389 | ||
|
|
97cfa2c1ad | ||
|
|
c018c6fcf5 | ||
|
|
70048328d1 | ||
|
|
55ddfe9181 | ||
|
|
ee41d156c7 | ||
|
|
5be2bc7360 | ||
|
|
e46ba4f506 | ||
|
|
7c8b969fa9 | ||
|
|
95515fd460 | ||
|
|
ce6cfc22ef | ||
|
|
4b3b441fc3 | ||
|
|
9194bf5a90 | ||
|
|
dc63a5839e | ||
|
|
d406846986 | ||
|
|
e85b07021e | ||
|
|
282200ac3d | ||
|
|
de8dea20d5 | ||
|
|
342fc2ab59 | ||
|
|
b8132ef393 | ||
|
|
2ede746d8a | ||
|
|
5bd0764bdd | ||
|
|
610948cd16 | ||
|
|
96bb99d6ec | ||
|
|
a090f180f4 | ||
|
|
c7e543d459 | ||
|
|
23e6b508f8 | ||
|
|
49a3989977 | ||
|
|
8eb2b60937 | ||
|
|
f02dcae52a | ||
|
|
098df5c0b5 | ||
|
|
684b77cbe6 | ||
|
|
81e9fc49fe | ||
|
|
7c696fc1ec | ||
|
|
fc27043e9e | ||
|
|
6ad1e27acf | ||
|
|
899047d9a2 | ||
|
|
78b5e2c1cc | ||
|
|
72f234027c | ||
|
|
730efe7b74 | ||
|
|
63885117e1 | ||
|
|
4f4c8905ff | ||
|
|
a5f6cb542d | ||
|
|
8456f47260 | ||
|
|
eb35fdc7a9 | ||
|
|
ceaf1e28f9 | ||
|
|
c3da23f5d3 | ||
|
|
44784b2236 | ||
|
|
157f6200f2 | ||
|
|
2882348547 | ||
|
|
e016cfab70 | ||
|
|
23b11e4096 | ||
|
|
7696872416 | ||
|
|
42d9fa70a2 | ||
|
|
a8a89def98 | ||
|
|
5bcce0c64a | ||
|
|
3a738fe701 | ||
|
|
d5670640f5 | ||
|
|
1d85eee78f | ||
|
|
5a46ab0055 | ||
|
|
3d5ff93a51 | ||
|
|
b9c66c7c2a | ||
|
|
68a390ef59 | ||
|
|
192ab1121c | ||
|
|
83eb33d54a | ||
|
|
ee937de2c4 | ||
|
|
8d514bd571 | ||
|
|
e83c404e21 | ||
|
|
945f55f50d | ||
|
|
9f83ea7111 | ||
|
|
f12c06e975 | ||
|
|
bbb176e153 | ||
|
|
02793040fd | ||
|
|
0773e83149 | ||
|
|
21205b4d19 | ||
|
|
60dbf6c11d | ||
|
|
2491ad7142 | ||
|
|
3b2834cf6d | ||
|
|
7ed2b23ea3 | ||
|
|
c879f82114 | ||
|
|
02a4740c66 | ||
|
|
6cb2702e6b | ||
|
|
94a9f7a84e | ||
|
|
e53465ce11 | ||
|
|
33d1f3c151 | ||
|
|
fc4eba2497 | ||
|
|
3e5f27c1d5 | ||
|
|
f2f64f7dd6 | ||
|
|
d842800df3 | ||
|
|
1af2ad0ec4 | ||
|
|
67915151aa | ||
|
|
de25b36a01 | ||
|
|
59e74e6eeb | ||
|
|
4e7f095b0f | ||
|
|
cdea75b87f | ||
|
|
6a0d2e21b5 | ||
|
|
b79d5fccbc | ||
|
|
6d77cb1801 | ||
|
|
e4a45a556c | ||
|
|
3ca39ceb8a | ||
|
|
8a93122882 | ||
|
|
8eb986591a | ||
|
|
c10808b611 | ||
|
|
ba63358098 | ||
|
|
52534db3e1 | ||
|
|
dc9b375ff5 | ||
|
|
65fdf115be | ||
|
|
ecb2b35ec8 | ||
|
|
2d13e0985e | ||
|
|
5014443f80 | ||
|
|
3fef7596b3 | ||
|
|
19042907be | ||
|
|
5cdd06d432 | ||
|
|
47e23bff90 | ||
|
|
7dfc62b2c5 | ||
|
|
39c4af0a7c | ||
|
|
57c5c394f5 | ||
|
|
be6da38a08 | ||
|
|
fc36ed08f1 | ||
|
|
ed90769081 | ||
|
|
a8310fa0ff | ||
|
|
a902e31521 | ||
|
|
932ab13d97 | ||
|
|
94a1ba7989 | ||
|
|
bfecdbf83a | ||
|
|
ba1cfc3c27 | ||
|
|
2cba228a67 | ||
|
|
66553ee236 | ||
|
|
64674b6a73 | ||
|
|
a9def8cb18 | ||
|
|
69186e9a26 | ||
|
|
f606826098 | ||
|
|
aff036d9fb | ||
|
|
57ed08994b | ||
|
|
131eefa1ac | ||
|
|
b4e639cc24 | ||
|
|
ba962af914 | ||
|
|
76514a6e2b | ||
|
|
b69a5342d9 | ||
|
|
c25682f199 | ||
|
|
eec8b4d2c3 | ||
|
|
1af7b797bc | ||
|
|
b5c159bf63 | ||
|
|
bfbdfb2b5c | ||
|
|
08bb64ddc1 | ||
|
|
23f90156bf | ||
|
|
1899cff572 | ||
|
|
774c2ce248 | ||
|
|
89d9075850 | ||
|
|
2c915d53f4 | ||
|
|
797d9442ac | ||
|
|
573d054748 | ||
|
|
2035a256f5 | ||
|
|
c94f26c8b9 | ||
|
|
fc2f14b3f4 | ||
|
|
6dd1697915 | ||
|
|
79e899c301 | ||
|
|
2194301716 | ||
|
|
0348894ab8 | ||
|
|
9b17d8bea1 | ||
|
|
69d6b6f934 | ||
|
|
6c106374fa | ||
|
|
af039d045d | ||
|
|
4c9caf09ba | ||
|
|
3fd02adbec | ||
|
|
90dac3cd15 | ||
|
|
d0307ee6d9 | ||
|
|
09d02b7ced | ||
|
|
56a26d9663 | ||
|
|
42f809f6d4 | ||
|
|
7d64c82987 | ||
|
|
6252227bb6 | ||
|
|
e9ac393a8f | ||
|
|
5b1745f991 | ||
|
|
0e55bf5c43 | ||
|
|
9f66f73501 | ||
|
|
c3da28b07f | ||
|
|
b035b96dec | ||
|
|
9623ac4141 | ||
|
|
c8edbd285b | ||
|
|
016597d5a2 | ||
|
|
52dea8fa2f | ||
|
|
0a37a8ea6d | ||
|
|
c1404ef904 | ||
|
|
2c0fce61df | ||
|
|
bbe9b6b6cf | ||
|
|
23231563c9 | ||
|
|
d75c8668c5 | ||
|
|
f266232b5a | ||
|
|
a8362e8e88 | ||
|
|
e4dfae1905 | ||
|
|
a09e740648 | ||
|
|
5ee6a43f08 | ||
|
|
8bd83cbfcd | ||
|
|
bc14d1d0f8 | ||
|
|
526e649f06 | ||
|
|
ac40eb8f7c | ||
|
|
c750cf10a8 | ||
|
|
4f4951cdcd | ||
|
|
50891afd05 | ||
|
|
cbb6fc740a | ||
|
|
31c3dd6119 | ||
|
|
15700ddd8d | ||
|
|
d8673a8cf7 | ||
|
|
a5af9f0776 | ||
|
|
d715e7b3b6 | ||
|
|
1da5a6a411 | ||
|
|
af5ffc22ac | ||
|
|
3434029654 | ||
|
|
6baa06bd3f | ||
|
|
8107d4f531 | ||
|
|
f8c8044605 | ||
|
|
a84f4de02c | ||
|
|
3c374e3cc7 | ||
|
|
ff364f8b3d | ||
|
|
c0cb12f002 | ||
|
|
0f0f812059 | ||
|
|
7fc59ed497 | ||
|
|
60120852f5 | ||
|
|
f2c389e2b3 | ||
|
|
305359ae15 | ||
|
|
e35671c450 | ||
|
|
15235a9bc2 | ||
|
|
b360bd8494 | ||
|
|
6a95d24441 | ||
|
|
e816f0afc8 | ||
|
|
7e8732822b | ||
|
|
9ed6b11bb1 | ||
|
|
62124ae475 | ||
|
|
c327928921 | ||
|
|
be26a9457f | ||
|
|
5dc43cbc8b | ||
|
|
9abf6888aa | ||
|
|
aff3b43c9d | ||
|
|
e8d95facdf | ||
|
|
a9f08df566 | ||
|
|
2fecbc1162 | ||
|
|
1fc3029d12 | ||
|
|
bbcb5e0cf1 | ||
|
|
e4a7ac0f3c | ||
|
|
24630791d8 | ||
|
|
97d00b678f | ||
|
|
52eb973164 | ||
|
|
789879a9cc | ||
|
|
52c52d53b7 | ||
|
|
54fe6a2319 | ||
|
|
bc5dcb0ed5 | ||
|
|
6c3f3f6a77 | ||
|
|
6e64bad1e2 | ||
|
|
0d5b2382ab | ||
|
|
39d0211593 | ||
|
|
86085f87a1 | ||
|
|
ebdcb4b2f0 | ||
|
|
3a0dff5b0e | ||
|
|
c682bce6f6 | ||
|
|
8dd7671d1f | ||
|
|
fe391523c8 | ||
|
|
399cf893ad | ||
|
|
f081f7826a | ||
|
|
638e1aedb7 | ||
|
|
dcbef9630e | ||
|
|
a745cb7498 | ||
|
|
d701195ae5 | ||
|
|
ac18d23fbc | ||
|
|
ff7914f6d3 | ||
|
|
647e6c1cf5 | ||
|
|
98b60ebe93 | ||
|
|
0b15ebba71 | ||
|
|
eee20033ae | ||
|
|
e642506675 | ||
|
|
883055b5fb | ||
|
|
968a1383f7 | ||
|
|
6a2030e235 | ||
|
|
4d2a73556a | ||
|
|
90027d3a5a | ||
|
|
61593bd807 | ||
|
|
99ebc9fc9c | ||
|
|
a5e798164c | ||
|
|
002b9340e3 | ||
|
|
f00f833ee2 | ||
|
|
3a6bc8c0f7 | ||
|
|
76368f1ae9 | ||
|
|
fab86f7f87 | ||
|
|
ac74db2fde | ||
|
|
b2480eea74 | ||
|
|
20a898c978 | ||
|
|
589d3abd8d | ||
|
|
1ba588d416 | ||
|
|
b1f37495d6 | ||
|
|
8c9cb43097 | ||
|
|
aeeed8feb5 | ||
|
|
1e89eb1a21 | ||
|
|
413e0bebad | ||
|
|
a2a184bb93 | ||
|
|
827d8cc8e1 | ||
|
|
833c53f5aa | ||
|
|
2775a74bdb | ||
|
|
450790366d | ||
|
|
7b04f664cd | ||
|
|
358508ffa3 | ||
|
|
9388c8f8f4 | ||
|
|
40d8c949d9 | ||
|
|
6b0b052d78 | ||
|
|
ac86a4e7f7 | ||
|
|
bbe5501297 | ||
|
|
b37acf3138 | ||
|
|
5bd78b8068 | ||
|
|
ed39c01608 | ||
|
|
748ebc8f26 | ||
|
|
03262878c4 |
6
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: If something is not working as expected 🤔.
|
about: If something is not working as expected 🤔.
|
||||||
|
labels: ["bug", "triage"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug Report
|
## Bug Report
|
||||||
@@ -18,8 +18,8 @@ A clear and concise description of what you expected to happen (or code).
|
|||||||
3. And then the bug happens!
|
3. And then the bug happens!
|
||||||
|
|
||||||
**Environment**
|
**Environment**
|
||||||
- Impress version:
|
- Docs version:
|
||||||
- Platform:
|
- Instance url:
|
||||||
|
|
||||||
**Possible Solution**
|
**Possible Solution**
|
||||||
<!--- Only if you have suggestions on a fix for the bug -->
|
<!--- Only if you have suggestions on a fix for the bug -->
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: I have a suggestion (and may want to build it 💪)!
|
about: I have a suggestion (and may want to build it 💪)!
|
||||||
|
labels: ["feature", "triage"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Request
|
## Feature Request
|
||||||
@@ -16,8 +16,8 @@ A clear and concise description of what you want to happen. Add any considered d
|
|||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
|
||||||
Maybe a screenshot or design?
|
Maybe add a screenshot or design?
|
||||||
|
|
||||||
**Do you want to work on it through a Pull Request?**
|
**Do you want to work on it through a Pull Request?**
|
||||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||||
|
|||||||
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
@@ -1,17 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: 🤗 Support Question
|
name: 🤗 Support Question
|
||||||
about: If you have a question 💬, or something was not clear from the docs!
|
about: If you have a question 💬, or something was not clear from the docs!
|
||||||
|
labels: ["support", "triage"]
|
||||||
---
|
---
|
||||||
|
## Support request
|
||||||
|
**Checks before filing**
|
||||||
|
Please make sure you have read our [main Readme](https://github.com/suitenumerique/docs).
|
||||||
|
|
||||||
<!-- ^ Click "Preview" for a nicer view! ^
|
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/docs/issues?q=is%3Aissue%20state%3Aopen%20label%3Asupport).
|
||||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
|
|
||||||
|
|
||||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
|
|
||||||
|
|
||||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||||
|
|
||||||
|
|||||||
77
.github/workflows/crowdin_download.yml
vendored
Normal file
77
.github/workflows/crowdin_download.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Download translations from Crowdin
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'release/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
install-dependencies:
|
||||||
|
uses: ./.github/workflows/dependencies.yml
|
||||||
|
with:
|
||||||
|
node_version: '20.x'
|
||||||
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Create empty source files
|
||||||
|
run: |
|
||||||
|
touch src/backend/locale/django.pot
|
||||||
|
mkdir -p src/frontend/packages/i18n/locales/impress/
|
||||||
|
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
|
||||||
|
# crowdin workflow
|
||||||
|
- name: crowdin action
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
config: crowdin/config.yml
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
create_pull_request: false
|
||||||
|
push_translations: false
|
||||||
|
push_sources: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
|
||||||
|
# Visit https://crowdin.com/settings#api-key to create this token
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|
||||||
|
CROWDIN_BASE_PATH: "../src/"
|
||||||
|
# frontend i18n
|
||||||
|
- name: Restore the frontend cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: "src/frontend/**/node_modules"
|
||||||
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
- name: generate translations files
|
||||||
|
working-directory: src/frontend
|
||||||
|
run: yarn i18n:deploy
|
||||||
|
# Create a new PR
|
||||||
|
- name: Create a new Pull Request with new translated strings
|
||||||
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
with:
|
||||||
|
commit-message: |
|
||||||
|
🌐(i18n) update translated strings
|
||||||
|
|
||||||
|
Update translated files with new translations
|
||||||
|
title: 🌐(i18n) update translated strings
|
||||||
|
body: |
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
update translated strings
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
- [x] update translated strings
|
||||||
|
branch: i18n/update-translations
|
||||||
|
labels: i18n
|
||||||
76
.github/workflows/crowdin_upload.yml
vendored
Normal file
76
.github/workflows/crowdin_upload.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
name: Update crowdin sources
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
install-dependencies:
|
||||||
|
uses: ./.github/workflows/dependencies.yml
|
||||||
|
with:
|
||||||
|
node_version: '20.x'
|
||||||
|
with-front-dependencies-installation: true
|
||||||
|
with-build_mails: true
|
||||||
|
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
needs: install-dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Backend i18n
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v3
|
||||||
|
with:
|
||||||
|
python-version: "3.13.3"
|
||||||
|
- name: Upgrade pip and setuptools
|
||||||
|
run: pip install --upgrade pip setuptools
|
||||||
|
- name: Install development dependencies
|
||||||
|
run: pip install --user .
|
||||||
|
working-directory: src/backend
|
||||||
|
- name: Restore the mail templates
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: mail-templates
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
- name: Install gettext
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gettext pandoc
|
||||||
|
- name: generate pot files
|
||||||
|
working-directory: src/backend
|
||||||
|
run: |
|
||||||
|
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||||
|
# frontend i18n
|
||||||
|
- name: Restore the frontend cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: "src/frontend/**/node_modules"
|
||||||
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
- name: generate source translation file
|
||||||
|
working-directory: src/frontend
|
||||||
|
run: yarn i18n:extract
|
||||||
|
# crowdin workflow
|
||||||
|
- name: crowdin action
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
config: crowdin/config.yml
|
||||||
|
upload_sources: true
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: false
|
||||||
|
create_pull_request: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
|
||||||
|
# Visit https://crowdin.com/settings#api-key to create this token
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|
||||||
|
CROWDIN_BASE_PATH: "../src/"
|
||||||
85
.github/workflows/dependencies.yml
vendored
Normal file
85
.github/workflows/dependencies.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: Dependency reusable workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
node_version:
|
||||||
|
required: false
|
||||||
|
default: '20.x'
|
||||||
|
type: string
|
||||||
|
with-front-dependencies-installation:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
with-build_mails:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
front-dependencies-installation:
|
||||||
|
if: ${{ inputs.with-front-dependencies-installation == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Restore the frontend cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: front-node_modules
|
||||||
|
with:
|
||||||
|
path: "src/frontend/**/node_modules"
|
||||||
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ inputs.node_version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||||
|
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||||
|
- name: Cache install frontend
|
||||||
|
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: "src/frontend/**/node_modules"
|
||||||
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
|
||||||
|
build-mails:
|
||||||
|
if: ${{ inputs.with-build_mails == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/mail
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore the mail templates
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: mail-templates
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ inputs.node_version }}
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: npm install -g yarn
|
||||||
|
|
||||||
|
- name: Install node dependencies
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build mails
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Cache mail templates
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
52
.github/workflows/deploy.yml
vendored
52
.github/workflows/deploy.yml
vendored
@@ -1,52 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'preprod'
|
|
||||||
- 'production'
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify-argocd:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repositories: "impress,secrets"
|
|
||||||
-
|
|
||||||
name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
-
|
|
||||||
name: Load sops secrets
|
|
||||||
uses: rouja/actions-sops@main
|
|
||||||
with:
|
|
||||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
-
|
|
||||||
name: Call argocd github webhook
|
|
||||||
run: |
|
|
||||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
|
||||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
|
||||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
|
||||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
|
||||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
|
|
||||||
|
|
||||||
start-test-on-preprod:
|
|
||||||
needs:
|
|
||||||
- notify-argocd
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.event.ref, 'refs/tags/preprod')
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Debug
|
|
||||||
run: |
|
|
||||||
echo "Start test when preprod is ready"
|
|
||||||
165
.github/workflows/docker-hub.yml
vendored
165
.github/workflows/docker-hub.yml
vendored
@@ -1,15 +1,11 @@
|
|||||||
name: Docker Hub Workflow
|
name: Docker Hub Workflow
|
||||||
|
run-name: Docker Hub Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'do-not-merge/hackathon-2025'
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
@@ -18,26 +14,9 @@ jobs:
|
|||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repositories: "impress,secrets"
|
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
-
|
|
||||||
name: Load sops secrets
|
|
||||||
uses: rouja/actions-sops@main
|
|
||||||
with:
|
|
||||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -46,42 +25,30 @@ jobs:
|
|||||||
images: lasuite/impress-backend
|
images: lasuite/impress-backend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
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
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
|
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
build-and-push-frontend:
|
build-and-push-frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repositories: "impress,secrets"
|
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
-
|
|
||||||
name: Load sops secrets
|
|
||||||
uses: rouja/actions-sops@main
|
|
||||||
with:
|
|
||||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -90,43 +57,33 @@ jobs:
|
|||||||
images: lasuite/impress-frontend
|
images: lasuite/impress-frontend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
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
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: |
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
PUBLISH_AS_MIT=false
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
build-and-push-y-provider:
|
build-and-push-y-provider:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repositories: "impress,secrets"
|
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
-
|
|
||||||
name: Load sops secrets
|
|
||||||
uses: rouja/actions-sops@main
|
|
||||||
with:
|
|
||||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -135,17 +92,45 @@ jobs:
|
|||||||
images: lasuite/impress-y-provider
|
images: lasuite/impress-y-provider
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
|
||||||
|
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-mcp-server:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: lasuite/impress-mcp-server
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: ./src/mcp_server
|
||||||
|
file: ./src/mcp_server/Dockerfile
|
||||||
|
build-args: |
|
||||||
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -154,32 +139,10 @@ jobs:
|
|||||||
- build-and-push-frontend
|
- build-and-push-frontend
|
||||||
- build-and-push-backend
|
- build-and-push-backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: |
|
|
||||||
github.event_name != 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
-
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
uses: actions/create-github-app-token@v1
|
id: notify
|
||||||
id: app-token
|
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||||
owner: ${{ github.repository_owner }}
|
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||||
repositories: "impress,secrets"
|
|
||||||
-
|
|
||||||
name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
-
|
|
||||||
name: Load sops secrets
|
|
||||||
uses: rouja/actions-sops@main
|
|
||||||
with:
|
|
||||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
|
||||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
|
||||||
-
|
|
||||||
name: Call argocd github webhook
|
|
||||||
run: |
|
|
||||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
|
||||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
|
||||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
|
||||||
|
|||||||
30
.github/workflows/helmfile-linter.yaml
vendored
Normal file
30
.github/workflows/helmfile-linter.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Helmfile lint
|
||||||
|
run-name: Helmfile lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
helmfile-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/helmfile/helmfile:v0.171.0
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
-
|
||||||
|
name: Helmfile lint
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
HELMFILE=src/helm/helmfile.yaml
|
||||||
|
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
|
||||||
|
echo -e "\n"
|
||||||
|
done
|
||||||
199
.github/workflows/impress-frontend.yml
vendored
199
.github/workflows/impress-frontend.yml
vendored
@@ -9,9 +9,16 @@ on:
|
|||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-front:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
|
install-dependencies:
|
||||||
|
uses: ./.github/workflows/dependencies.yml
|
||||||
|
with:
|
||||||
|
node_version: '20.x'
|
||||||
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
|
test-front:
|
||||||
|
needs: install-dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -19,155 +26,72 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18.x"
|
node-version: "20.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: front-node_modules
|
|
||||||
with:
|
|
||||||
path: "src/frontend/**/node_modules"
|
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
|
||||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Cache install frontend
|
|
||||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: "src/frontend/**/node_modules"
|
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
|
||||||
|
|
||||||
build-front:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: install-front
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: front-node_modules
|
|
||||||
with:
|
|
||||||
path: "src/frontend/**/node_modules"
|
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
|
||||||
|
|
||||||
- name: Build CI App
|
|
||||||
run: cd src/frontend/ && yarn ci:build
|
|
||||||
|
|
||||||
- name: Cache build frontend
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: src/frontend/apps/impress/out/
|
|
||||||
key: build-front-${{ github.run_id }}
|
|
||||||
|
|
||||||
test-front:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: install-front
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: front-node_modules
|
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
path: "src/frontend/**/node_modules"
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Test App
|
- name: Test App
|
||||||
run: cd src/frontend/ && yarn app:test
|
run: cd src/frontend/ && yarn test
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: install-front
|
needs: install-dependencies
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20.x"
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: front-node_modules
|
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
path: "src/frontend/**/node_modules"
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: cd src/frontend/ && yarn lint
|
run: cd src/frontend/ && yarn lint
|
||||||
|
|
||||||
test-e2e-chromium:
|
test-e2e-chromium:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-front
|
needs: install-dependencies
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set services env variables
|
- name: Setup Node.js
|
||||||
run: |
|
uses: actions/setup-node@v4
|
||||||
make data/media
|
|
||||||
make create-env-files
|
|
||||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
|
||||||
|
|
||||||
- name: Restore the mail templates
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: mail-templates
|
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
node-version: "20.x"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: front-node_modules
|
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
path: "src/frontend/**/node_modules"
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Restore the build cache
|
- name: Set e2e env variables
|
||||||
uses: actions/cache@v4
|
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||||
id: cache-build
|
|
||||||
with:
|
|
||||||
path: src/frontend/apps/impress/out/
|
|
||||||
key: build-front-${{ github.run_id }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build the Docker images
|
|
||||||
uses: docker/bake-action@v4
|
|
||||||
with:
|
|
||||||
targets: |
|
|
||||||
app-dev
|
|
||||||
y-provider
|
|
||||||
load: true
|
|
||||||
set: |
|
|
||||||
*.cache-from=type=gha,scope=cached-stage
|
|
||||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
|
||||||
|
|
||||||
- name: Start Docker services
|
|
||||||
run: |
|
|
||||||
make run
|
|
||||||
|
|
||||||
- name: Start Nginx for the frontend
|
|
||||||
run: |
|
|
||||||
docker compose up --force-recreate -d nginx-front
|
|
||||||
|
|
||||||
- name: Apply DRF migrations
|
|
||||||
run: |
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
- name: Add dummy data
|
|
||||||
run: |
|
|
||||||
make demo FLUSH_ARGS='--no-input'
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||||
|
|
||||||
|
- name: Start Docker services
|
||||||
|
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-chromium-report
|
name: playwright-chromium-report
|
||||||
@@ -176,76 +100,37 @@ jobs:
|
|||||||
|
|
||||||
test-e2e-other-browser:
|
test-e2e-other-browser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-front
|
needs: test-e2e-chromium
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set services env variables
|
- name: Setup Node.js
|
||||||
run: |
|
uses: actions/setup-node@v4
|
||||||
make data/media
|
|
||||||
make create-env-files
|
|
||||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
|
||||||
|
|
||||||
- name: Restore the mail templates
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: mail-templates
|
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
node-version: "20.x"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: front-node_modules
|
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
path: "src/frontend/**/node_modules"
|
||||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Restore the build cache
|
- name: Set e2e env variables
|
||||||
uses: actions/cache@v4
|
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||||
id: cache-build
|
|
||||||
with:
|
|
||||||
path: src/frontend/apps/impress/out/
|
|
||||||
key: build-front-${{ github.run_id }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build the Docker images
|
|
||||||
uses: docker/bake-action@v4
|
|
||||||
with:
|
|
||||||
targets: |
|
|
||||||
app-dev
|
|
||||||
y-provider
|
|
||||||
load: true
|
|
||||||
set: |
|
|
||||||
*.cache-from=type=gha,scope=cached-stage
|
|
||||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
|
||||||
|
|
||||||
- name: Start Docker services
|
|
||||||
run: |
|
|
||||||
make run
|
|
||||||
|
|
||||||
- name: Start Nginx for the frontend
|
|
||||||
run: |
|
|
||||||
docker compose up --force-recreate -d nginx-front
|
|
||||||
|
|
||||||
- name: Apply DRF migrations
|
|
||||||
run: |
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
- name: Add dummy data
|
|
||||||
run: |
|
|
||||||
make demo FLUSH_ARGS='--no-input'
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||||
|
|
||||||
|
- name: Start Docker services
|
||||||
|
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-other-report
|
name: playwright-other-report
|
||||||
|
|||||||
81
.github/workflows/impress.yml
vendored
81
.github/workflows/impress.yml
vendored
@@ -9,6 +9,11 @@ on:
|
|||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
install-dependencies:
|
||||||
|
uses: ./.github/workflows/dependencies.yml
|
||||||
|
with:
|
||||||
|
with-build_mails: true
|
||||||
|
|
||||||
lint-git:
|
lint-git:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
@@ -56,45 +61,24 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-mails:
|
lint-spell-mistakes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
if: github.event_name == 'pull_request'
|
||||||
run:
|
|
||||||
working-directory: src/mail
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
|
- name: Install codespell
|
||||||
- name: Install Node.js
|
run: pip install --user codespell
|
||||||
uses: actions/setup-node@v4
|
- name: Check for typos
|
||||||
with:
|
run: |
|
||||||
node-version: "18"
|
codespell \
|
||||||
|
--check-filenames \
|
||||||
- name: Restore the mail templates
|
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||||
uses: actions/cache@v4
|
--skip "./git/" \
|
||||||
id: mail-templates
|
--skip "**/*.po" \
|
||||||
with:
|
--skip "**/*.pot" \
|
||||||
path: "src/backend/core/templates/mail"
|
--skip "**/*.json" \
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
--skip "**/yarn.lock"
|
||||||
|
|
||||||
- name: Install yarn
|
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
|
||||||
run: npm install -g yarn
|
|
||||||
|
|
||||||
- name: Install node dependencies
|
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build mails
|
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
|
||||||
run: yarn build
|
|
||||||
|
|
||||||
- name: Cache mail templates
|
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: "src/backend/core/templates/mail"
|
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
|
||||||
|
|
||||||
lint-back:
|
lint-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -107,7 +91,9 @@ jobs:
|
|||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.13.3"
|
||||||
|
- name: Upgrade pip and setuptools
|
||||||
|
run: pip install --upgrade pip setuptools
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
@@ -119,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-mails
|
needs: install-dependencies
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -167,8 +153,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Start Minio
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
docker pull minio/minio
|
docker pull minio/minio
|
||||||
docker run -d --name minio \
|
docker run -d --name minio \
|
||||||
@@ -178,6 +165,15 @@ jobs:
|
|||||||
-v /data/media:/data \
|
-v /data/media:/data \
|
||||||
minio/minio server --console-address :9001 /data
|
minio/minio server --console-address :9001 /data
|
||||||
|
|
||||||
|
# Tool to wait for a service to be ready
|
||||||
|
- name: Install Dockerize
|
||||||
|
run: |
|
||||||
|
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||||
|
|
||||||
|
- name: Wait for MinIO to be ready
|
||||||
|
run: |
|
||||||
|
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||||
|
|
||||||
- name: Configure MinIO
|
- name: Configure MinIO
|
||||||
run: |
|
run: |
|
||||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||||
@@ -190,15 +186,16 @@ jobs:
|
|||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.13.3"
|
||||||
|
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages)
|
- name: Install gettext (required to compile messages) and MIME support
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y gettext pandoc
|
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||||
|
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
- name: Generate a MO file from strings extracted from the project
|
||||||
run: python manage.py compilemessages
|
run: python manage.py compilemessages
|
||||||
|
|||||||
34
.github/workflows/release-helm-chart.yaml
vendored
Normal file
34
.github/workflows/release-helm-chart.yaml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Release Chart
|
||||||
|
run-name: Release Chart
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- src/helm/impress/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||||
|
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: rm -rf ./src/helm/extra
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Publish Helm charts
|
||||||
|
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||||
|
with:
|
||||||
|
charts_dir: ./src/helm
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ MANIFEST
|
|||||||
.next/
|
.next/
|
||||||
|
|
||||||
# Translations # Translations
|
# Translations # Translations
|
||||||
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
|
|||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "secrets"]
|
|
||||||
path = secrets
|
|
||||||
url = ../secrets
|
|
||||||
|
|||||||
488
CHANGELOG.md
488
CHANGELOG.md
@@ -6,9 +6,455 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
|||||||
and this project adheres to
|
and this project adheres to
|
||||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🚩(backend) add feature flag for the footer #841
|
||||||
|
- 🔧(backend) add view to manage footer json #841
|
||||||
|
- ✨(frontend) add custom css style #771
|
||||||
|
- 🚩(frontend) conditionally render AI button only when feature is enabled #814
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🚨(frontend) block button when creating doc #749
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(back) validate document content in serializer #822
|
||||||
|
- 🐛(frontend) fix selection click past end of content #840
|
||||||
|
|
||||||
|
## [3.0.0] - 2025-03-28
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 📄(legal) Require contributors to sign a DCO #779
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) Integrate UI kit #783
|
||||||
|
- 🏗️(y-provider) manage auth in y-provider app #804
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||||
|
- 🔒️(back) restrict access to document accesses #801
|
||||||
|
|
||||||
|
|
||||||
|
## [2.6.0] - 2025-03-21
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 📝(doc) add publiccode.yml #770
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🚸(frontend) ctrl+k modal not when editor is focused #712
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(back) allow only images to be used with the cors-proxy #781
|
||||||
|
- 🐛(backend) stop returning inactive users on the list endpoint #636
|
||||||
|
- 🔒️(backend) require at least 5 characters to search for users #636
|
||||||
|
- 🔒️(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
|
||||||
|
|
||||||
|
- 📝(doc) Added GNU Make link to README #750
|
||||||
|
- ✨(frontend) add pinning on doc detail #711
|
||||||
|
- 🚩(frontend) feature flag analytic on copy as html #649
|
||||||
|
- ✨(frontend) Custom block divider with export #698
|
||||||
|
- 🌐(i18n) activate dutch language #742
|
||||||
|
- ✨(frontend) add Beautify action to AI transform #478
|
||||||
|
- ✨(frontend) add Emojify action to AI transform #478
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🧑💻(frontend) change literal section open source #702
|
||||||
|
- ♻️(frontend) replace cors proxy for export #695
|
||||||
|
- 🚨(gitlint) Allow uppercase in commit messages #756
|
||||||
|
- ♻️(frontend) Improve AI translations #478
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) SVG export #706
|
||||||
|
- 🐛(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
|
||||||
|
- 🐛(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
|
||||||
|
|
||||||
|
- ✨(frontend) synchronize language-choice #401
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Use sentry tags instead of extra scope
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix collaboration error #684
|
||||||
|
|
||||||
|
|
||||||
|
## [2.3.0] - 2025-03-03
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) limit link reach/role select options depending on ancestors #645
|
||||||
|
- ✨(backend) add new "descendants" action to document API endpoint #645
|
||||||
|
- ✨(backend) new "tree" action on document detail endpoint #645
|
||||||
|
- ✨(backend) allow forcing page size within limits #645
|
||||||
|
- 💄(frontend) add error pages #643
|
||||||
|
- 🔒️ Manage unsafe attachments #663
|
||||||
|
- ✨(frontend) Custom block quote with export #646
|
||||||
|
- ✨(frontend) add open source section homepage #666
|
||||||
|
- ✨(frontend) synchronize language-choice #401
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🛂(frontend) Restore version visibility #629
|
||||||
|
- 📝(doc) minor README.md formatting and wording enhancements
|
||||||
|
- ♻️Stop setting a default title on doc creation #634
|
||||||
|
- ♻️(frontend) misc ui improvements #644
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) allow any type of extensions for media download #671
|
||||||
|
- ♻️(frontend) improve table pdf rendering
|
||||||
|
- 🐛(email) invitation emails in receivers language
|
||||||
|
|
||||||
|
## [2.2.0] - 2025-02-10
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 📝(doc) Add security.md and codeofconduct.md #604
|
||||||
|
- ✨(frontend) add home page #608
|
||||||
|
- ✨(frontend) cursor display on activity #609
|
||||||
|
- ✨(frontend) Add export page break #623
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🔧(backend) make AI feature reach configurable #628
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🌐(CI) Fix email partially translated #616
|
||||||
|
- 🐛(frontend) fix cursor breakline #609
|
||||||
|
- 🐛(frontend) fix style pdf export #609
|
||||||
|
|
||||||
|
## [2.1.0] - 2025-01-29
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) add duplicate action to the document API endpoint
|
||||||
|
- ⚗️(backend) add util to extract text from base64 yjs document
|
||||||
|
- ✨(backend) add soft delete and restore API endpoints to documents #516
|
||||||
|
- ✨(backend) allow organizing documents in a tree structure #516
|
||||||
|
- ✨(backend) add "excerpt" field to document list serializer #516
|
||||||
|
- ✨(backend) add github actions to manage Crowdin workflow #559 & #563
|
||||||
|
- 📈Integrate Posthog #540
|
||||||
|
- 🏷️(backend) add content-type to uploaded files #552
|
||||||
|
- ✨(frontend) export pdf docx front side #537
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 💄(frontend) add abilities on doc row #581
|
||||||
|
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||||
|
- 🔧(backend) Bump maximum page size to 200 #516
|
||||||
|
- 📝(doc) Improve Read me #558
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛Fix invitations #575
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- 🔥(backend) remove "content" field from list serializer # 516
|
||||||
|
|
||||||
|
## [2.0.1] - 2025-01-17
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
-🐛(frontend) share modal is shown when you don't have the abilities #557
|
||||||
|
-🐛(frontend) title copy break app #564
|
||||||
|
|
||||||
|
## [2.0.0] - 2025-01-13
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
|
||||||
|
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
||||||
|
- 💄(frontend) Add left panel #420
|
||||||
|
- 💄(frontend) add filtering to left panel #475
|
||||||
|
- ✨(frontend) new share modal ui #489
|
||||||
|
- ✨(frontend) add favorite feature #515
|
||||||
|
- 📝(documentation) Documentation about self-hosted installation #530
|
||||||
|
- ✨(helm) helm versioning #530
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🏗️(yjs-server) organize yjs server #528
|
||||||
|
- ♻️(frontend) better separation collaboration process #528
|
||||||
|
- 💄(frontend) updating the header and leftpanel for responsive #421
|
||||||
|
- 💄(frontend) update DocsGrid component #431
|
||||||
|
- 💄(frontend) update DocsGridOptions component #432
|
||||||
|
- 💄(frontend) update DocHeader ui #448
|
||||||
|
- 💄(frontend) update doc versioning ui #463
|
||||||
|
- 💄(frontend) update doc summary ui #473
|
||||||
|
- 📝(doc) update readme.md to match V2 changes #558 & #572
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
|
||||||
|
- 🐛(frontend) hide search and create doc button if not authenticated #555
|
||||||
|
- 🐛(backend) race condition creation issue #556
|
||||||
|
|
||||||
|
## [1.10.0] - 2024-12-17
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) add server-to-server API endpoint to create documents #467
|
||||||
|
- ✨(email) white brand email #412
|
||||||
|
- ✨(y-provider) create a markdown converter endpoint #488
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ⚡️(docker) improve y-provider image #422
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- ⚡️(e2e) reduce flakiness on e2e tests #511
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) update doc editor height #481
|
||||||
|
- 💄(frontend) add doc search #485
|
||||||
|
|
||||||
|
## [1.9.0] - 2024-12-11
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) annotate number of accesses on documents in list view #429
|
||||||
|
- ✨(backend) allow users to mark/unmark documents as favorite #429
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🔒️(collaboration) increase collaboration access security #472
|
||||||
|
- 🔨(frontend) encapsulated title to its own component #474
|
||||||
|
- ⚡️(backend) optimize number of queries on document list view #429
|
||||||
|
- ♻️(frontend) stop to use provider with version #480
|
||||||
|
- 🚚(collaboration) change the websocket key name #480
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix initial content with collaboration #484
|
||||||
|
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||||
|
- 🐛(backend) fix sanitize problem IA #490
|
||||||
|
|
||||||
|
## [1.8.2] - 2024-11-28
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ♻️(SW) change strategy html caching #460
|
||||||
|
|
||||||
|
## [1.8.1] - 2024-11-27
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||||
|
|
||||||
|
## [1.8.0] - 2024-11-25
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🌐(backend) add German translation #259
|
||||||
|
- 🌐(frontend) add German translation #255
|
||||||
|
- ✨(frontend) add a broadcast store #387
|
||||||
|
- ✨(backend) whitelist pod's IP address #443
|
||||||
|
- ✨(backend) config endpoint #425
|
||||||
|
- ✨(frontend) config endpoint #424
|
||||||
|
- ✨(frontend) add sentry #424
|
||||||
|
- ✨(frontend) add crisp chatbot #450
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🚸(backend) improve users similarity search and sort results #391
|
||||||
|
- ♻️(frontend) simplify stores #402
|
||||||
|
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
|
||||||
|
- ✅(CI) trivy continue on error #453
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🔧(backend) fix logging for docker and make it configurable by envar #427
|
||||||
|
- 🦺(backend) add comma to sub regex #408
|
||||||
|
- 🐛(editor) collaborative user tag hidden when read only #385
|
||||||
|
- 🐛(frontend) users have view access when revoked #387
|
||||||
|
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||||
|
|
||||||
|
## [1.7.0] - 2024-10-24
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 📝Contributing.md #352
|
||||||
|
- 🌐(frontend) add localization to editor #368
|
||||||
|
- ✨Public and restricted doc editable #357
|
||||||
|
- ✨(frontend) Add full name if available #380
|
||||||
|
- ✨(backend) Add view accesses ability #376
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) list accesses if user has abilities #376
|
||||||
|
- ♻️(frontend) avoid documents indexing in search engine #372
|
||||||
|
- 👔(backend) doc restricted by default #388
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) require right to manage document accesses to see invitations #369
|
||||||
|
- 🐛(i18n) same frontend and backend language using shared cookies #365
|
||||||
|
- 🐛(frontend) add default toolbar buttons #355
|
||||||
|
- 🐛(frontend) throttle error correctly display #378
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- 🔥(helm) remove infra related codes #366
|
||||||
|
|
||||||
|
## [1.6.0] - 2024-10-17
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨AI to doc editor #250
|
||||||
|
- ✨(backend) allow uploading more types of attachments #309
|
||||||
|
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) more multi theme friendly #325
|
||||||
|
- ♻️ Bootstrap frontend #257
|
||||||
|
- ♻️ Add username in email #314
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🛂(backend) do not duplicate user when disabled
|
||||||
|
- 🐛(frontend) invalidate queries after removing user #336
|
||||||
|
- 🐛(backend) Fix dysfunctional permissions on document create #329
|
||||||
|
- 🐛(backend) fix nginx docker container #340
|
||||||
|
- 🐛(frontend) fix copy paste firefox #353
|
||||||
|
|
||||||
|
## [1.5.1] - 2024-10-10
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(db) fix users duplicate #316
|
||||||
|
|
||||||
|
## [1.5.0] - 2024-10-09
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) add name fields to the user synchronized with OIDC #301
|
||||||
|
- ✨(ci) add security scan #291
|
||||||
|
- ♻️(frontend) Add versions #277
|
||||||
|
- ✨(frontend) one-click document creation #275
|
||||||
|
- ✨(frontend) edit title inline #275
|
||||||
|
- 📱(frontend) mobile responsive #304
|
||||||
|
- 🌐(frontend) Update translation #308
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 💄(frontend) error alert closeable on editor #284
|
||||||
|
- ♻️(backend) Change email content #283
|
||||||
|
- 🛂(frontend) viewers and editors can access share modal #302
|
||||||
|
- ♻️(frontend) remove footer on doc editor #313
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🛂(frontend) match email if no existing user matches the sub
|
||||||
|
- 🐛(backend) gitlab oicd userinfo endpoint #232
|
||||||
|
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
|
||||||
|
- ♻️(backend) getting list of document versions available for a user #258
|
||||||
|
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||||
|
- 🐛(frontend) fix editor break line not working #302
|
||||||
|
|
||||||
|
## [1.4.0] - 2024-09-17
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- ✨Add link public/authenticated/restricted access with read/editor roles #234
|
- ✨Add link public/authenticated/restricted access with read/editor roles #234
|
||||||
@@ -17,16 +463,16 @@ and this project adheres to
|
|||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- ♻️ Allow null titles on documents for easier creation #234
|
- ♻️(backend) Allow null titles on documents for easier creation #234
|
||||||
- 🛂(backend) stop to list public doc to everyone #234
|
- 🛂(backend) stop to list public doc to everyone #234
|
||||||
- 🚚(frontend) change visibility in share modal #235
|
- 🚚(frontend) change visibility in share modal #235
|
||||||
|
- ⚡️(frontend) Improve summary #244
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛 Fix forcing ID when creating a document via API endpoint #234
|
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||||
- 🐛 Rebuild frontend dev container from makefile #248
|
- 🐛 Rebuild frontend dev container from makefile #248
|
||||||
|
|
||||||
|
|
||||||
## [1.3.0] - 2024-09-05
|
## [1.3.0] - 2024-09-05
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -51,7 +497,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🔥(frontend) remove saving modal #213
|
- 🔥(frontend) remove saving modal #213
|
||||||
|
|
||||||
|
|
||||||
## [1.2.1] - 2024-08-23
|
## [1.2.1] - 2024-08-23
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
@@ -59,7 +504,6 @@ and this project adheres to
|
|||||||
- ♻️ Change ordering docs datagrid #195
|
- ♻️ Change ordering docs datagrid #195
|
||||||
- 🔥(helm) use scaleway email #194
|
- 🔥(helm) use scaleway email #194
|
||||||
|
|
||||||
|
|
||||||
## [1.2.0] - 2024-08-22
|
## [1.2.0] - 2024-08-22
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -83,8 +527,8 @@ and this project adheres to
|
|||||||
- ⚡️(CI) only e2e chrome mandatory #177
|
- ⚡️(CI) only e2e chrome mandatory #177
|
||||||
|
|
||||||
## Removed
|
## Removed
|
||||||
- 🔥(helm) remove htaccess #181
|
|
||||||
|
|
||||||
|
- 🔥(helm) remove htaccess #181
|
||||||
|
|
||||||
## [1.1.0] - 2024-07-15
|
## [1.1.0] - 2024-07-15
|
||||||
|
|
||||||
@@ -101,7 +545,6 @@ and this project adheres to
|
|||||||
- ♻️(frontend) create a doc from a modal #132
|
- ♻️(frontend) create a doc from a modal #132
|
||||||
- ♻️(frontend) manage members from the share modal #140
|
- ♻️(frontend) manage members from the share modal #140
|
||||||
|
|
||||||
|
|
||||||
## [1.0.0] - 2024-07-02
|
## [1.0.0] - 2024-07-02
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -129,7 +572,7 @@ and this project adheres to
|
|||||||
- ⚡️(e2e) unique login between tests (#80)
|
- ⚡️(e2e) unique login between tests (#80)
|
||||||
- ⚡️(CI) improve e2e job (#86)
|
- ⚡️(CI) improve e2e job (#86)
|
||||||
- ♻️(frontend) improve the error and message info ui (#93)
|
- ♻️(frontend) improve the error and message info ui (#93)
|
||||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
@@ -140,7 +583,6 @@ and this project adheres to
|
|||||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||||
- 🔥(frontend) Remove coming soon page (#121)
|
- 🔥(frontend) Remove coming soon page (#121)
|
||||||
|
|
||||||
|
|
||||||
## [0.1.0] - 2024-05-24
|
## [0.1.0] - 2024-05-24
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -148,8 +590,30 @@ and this project adheres to
|
|||||||
- ✨(frontend) Coming Soon page (#67)
|
- ✨(frontend) Coming Soon page (#67)
|
||||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||||
|
|
||||||
|
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main
|
||||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
|
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
|
||||||
|
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
|
||||||
|
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
|
||||||
|
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
||||||
|
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
||||||
|
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||||
|
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||||
|
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||||
|
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||||
|
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||||
|
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||||
|
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||||
|
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||||
|
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
||||||
|
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
||||||
|
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
||||||
|
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
||||||
|
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
||||||
|
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
||||||
|
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||||
|
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||||
|
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||||
|
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
[1.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.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.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||||
|
|||||||
79
CODE_OF_CONDUCT.md
Normal file
79
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
- Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at docs@numerique.gouv.fr.
|
||||||
|
|
||||||
|
- All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
- All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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](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.
|
||||||
102
CONTRIBUTING.md
Normal file
102
CONTRIBUTING.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Contributing to the Project
|
||||||
|
|
||||||
|
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||||
|
|
||||||
|
To get started with the project, please refer to the [README.md](https://github.com/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 --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 😊 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
|
||||||
|
|
||||||
|
When creating an issue, please provide the following details:
|
||||||
|
|
||||||
|
1. **Title**: A concise and descriptive title for the issue.
|
||||||
|
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||||
|
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||||
|
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||||
|
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||||
|
|
||||||
|
## Selecting an issue
|
||||||
|
|
||||||
|
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
|
||||||
|
|
||||||
|
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
All commit messages must adhere to the following format:
|
||||||
|
|
||||||
|
`<gitmoji>(type) title description`
|
||||||
|
|
||||||
|
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||||
|
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||||
|
* **title**: A short, descriptive title for the change (*)
|
||||||
|
* **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
|
||||||
|
|
||||||
|
```
|
||||||
|
✨(frontend) add user authentication logic
|
||||||
|
|
||||||
|
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog Update
|
||||||
|
|
||||||
|
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||||
|
|
||||||
|
### Example Changelog Message
|
||||||
|
```
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- ✨(frontend) add AI to the project #321
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||||
|
|
||||||
|
### Don't forget to:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||||
|
|
||||||
|
## Asking for Help
|
||||||
|
|
||||||
|
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||||
|
|
||||||
|
Thank you for your contributions! 👍
|
||||||
|
|
||||||
|
## 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).
|
||||||
76
Dockerfile
76
Dockerfile
@@ -1,21 +1,27 @@
|
|||||||
# Django impress
|
# Django impress
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.10-slim-bullseye as base
|
FROM python:3.13.3-alpine AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apt-get update && \
|
RUN apk update && \
|
||||||
apt-get -y upgrade && \
|
apk upgrade
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base as back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
|
# Install Rust and Cargo using Alpine's package manager
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
libffi-dev \
|
||||||
|
rust \
|
||||||
|
cargo
|
||||||
|
|
||||||
# Copy required python dependencies
|
# Copy required python dependencies
|
||||||
COPY ./src/backend /builder
|
COPY ./src/backend /builder
|
||||||
|
|
||||||
@@ -24,7 +30,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 as mail-builder
|
FROM node:24 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -35,15 +41,13 @@ RUN yarn install --frozen-lockfile && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
FROM base as link-collector
|
FROM base AS link-collector
|
||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Install libpangocairo & rdfind
|
# Install pango & rdfind
|
||||||
RUN apt-get update && \
|
RUN apk add \
|
||||||
apt-get install -y \
|
pango \
|
||||||
libpangocairo-1.0-0 \
|
rdfind
|
||||||
rdfind && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy installed python dependencies
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /install /usr/local
|
COPY --from=back-builder /install /usr/local
|
||||||
@@ -54,7 +58,7 @@ COPY ./src/backend /app/
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# collectstatic
|
# collectstatic
|
||||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
RUN DJANGO_CONFIGURATION=Build \
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||||
@@ -62,23 +66,23 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
|||||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
|
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
|
||||||
|
|
||||||
# ---- Core application image ----
|
# ---- Core application image ----
|
||||||
FROM base as core
|
FROM base AS core
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install required system libs
|
# Install required system libs
|
||||||
RUN apt-get update && \
|
RUN apk add \
|
||||||
apt-get install -y \
|
cairo \
|
||||||
gettext \
|
file \
|
||||||
libcairo2 \
|
font-noto \
|
||||||
libffi-dev \
|
font-noto-emoji \
|
||||||
libgdk-pixbuf2.0-0 \
|
gettext \
|
||||||
libpango-1.0-0 \
|
gdk-pixbuf \
|
||||||
libpangocairo-1.0-0 \
|
libffi-dev \
|
||||||
pandoc \
|
pango \
|
||||||
fonts-noto-color-emoji \
|
shared-mime-info
|
||||||
shared-mime-info && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||||
|
|
||||||
# Copy entrypoint
|
# Copy entrypoint
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
@@ -96,21 +100,24 @@ COPY ./src/backend /app/
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Generate compiled translation messages
|
||||||
|
RUN DJANGO_CONFIGURATION=Build \
|
||||||
|
python manage.py compilemessages
|
||||||
|
|
||||||
|
|
||||||
# We wrap commands run in this container by the following entrypoint that
|
# We wrap commands run in this container by the following entrypoint that
|
||||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||||
# ID.
|
# ID.
|
||||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||||
|
|
||||||
# ---- Development image ----
|
# ---- Development image ----
|
||||||
FROM core as backend-development
|
FROM core AS backend-development
|
||||||
|
|
||||||
# Switch back to the root user to install development dependencies
|
# Switch back to the root user to install development dependencies
|
||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apt-get update && \
|
RUN apk add postgresql-client
|
||||||
apt-get install -y postgresql-client && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Uninstall impress and re-install it in editable mode along with development
|
# Uninstall impress and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
@@ -130,7 +137,10 @@ ENV DB_HOST=postgresql \
|
|||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core as backend-production
|
FROM core AS backend-production
|
||||||
|
|
||||||
|
# Remove apk cache, we don't need it anymore
|
||||||
|
RUN rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
|
|||||||
58
Makefile
58
Makefile
@@ -44,7 +44,6 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
|||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
@@ -81,20 +80,37 @@ bootstrap: \
|
|||||||
data/static \
|
data/static \
|
||||||
create-env-files \
|
create-env-files \
|
||||||
build \
|
build \
|
||||||
run-frontend-dev \
|
|
||||||
migrate \
|
migrate \
|
||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build
|
mails-build \
|
||||||
|
run
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
build: ## build the app-dev container
|
build: cache ?= --no-cache
|
||||||
@$(COMPOSE) build app-dev --no-cache
|
build: ## build the project containers
|
||||||
@$(COMPOSE) build frontend-dev --no-cache
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
|
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||||
|
@$(MAKE) build-frontend cache=$(cache)
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
|
build-backend: cache ?=
|
||||||
|
build-backend: ## build the app-dev container
|
||||||
|
@$(COMPOSE) build app-dev $(cache)
|
||||||
|
.PHONY: build-backend
|
||||||
|
|
||||||
|
build-yjs-provider: cache ?=
|
||||||
|
build-yjs-provider: ## build the y-provider container
|
||||||
|
@$(COMPOSE) build y-provider $(cache)
|
||||||
|
.PHONY: build-yjs-provider
|
||||||
|
|
||||||
|
build-frontend: cache ?=
|
||||||
|
build-frontend: ## build the frontend container
|
||||||
|
@$(COMPOSE) build frontend $(cache)
|
||||||
|
.PHONY: build-frontend
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
@@ -103,11 +119,16 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
@$(COMPOSE) logs -f app-dev
|
@$(COMPOSE) logs -f app-dev
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
|
|
||||||
run: ## start the wsgi (production) and development server
|
run-backend: ## Start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||||
@$(COMPOSE) up --force-recreate -d y-provider
|
@$(COMPOSE) up --force-recreate -d y-provider
|
||||||
@echo "Wait for postgresql to be up..."
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
@$(WAIT_DB)
|
.PHONY: run-backend
|
||||||
|
|
||||||
|
run: ## start the wsgi (production) and development server
|
||||||
|
run:
|
||||||
|
@$(MAKE) run-backend
|
||||||
|
@$(COMPOSE) up --force-recreate -d frontend
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
status: ## an alias for "docker compose ps"
|
||||||
@@ -165,14 +186,12 @@ test-back-parallel: ## run all back-end tests in parallel
|
|||||||
makemigrations: ## run django makemigrations for the impress project.
|
makemigrations: ## run django makemigrations for the impress project.
|
||||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@$(WAIT_DB)
|
|
||||||
@$(MANAGE) makemigrations
|
@$(MANAGE) makemigrations
|
||||||
.PHONY: makemigrations
|
.PHONY: makemigrations
|
||||||
|
|
||||||
migrate: ## run django migrations for the impress project.
|
migrate: ## run django migrations for the impress project.
|
||||||
@echo "$(BOLD)Running migrations$(RESET)"
|
@echo "$(BOLD)Running migrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@$(WAIT_DB)
|
|
||||||
@$(MANAGE) migrate
|
@$(MANAGE) migrate
|
||||||
.PHONY: migrate
|
.PHONY: migrate
|
||||||
|
|
||||||
@@ -287,9 +306,18 @@ help:
|
|||||||
.PHONY: help
|
.PHONY: help
|
||||||
|
|
||||||
# Front
|
# Front
|
||||||
run-frontend-dev: ## Install and run the frontend dev
|
frontend-development-install: ## install the frontend locally
|
||||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||||
.PHONY: run-frontend-dev
|
.PHONY: frontend-development-install
|
||||||
|
|
||||||
|
frontend-lint: ## run the frontend linter
|
||||||
|
cd $(PATH_FRONT) && yarn lint
|
||||||
|
.PHONY: frontend-lint
|
||||||
|
|
||||||
|
run-frontend-development: ## Run the frontend in development mode
|
||||||
|
@$(COMPOSE) stop frontend
|
||||||
|
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||||
|
.PHONY: run-frontend-development
|
||||||
|
|
||||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||||
cd $(PATH_FRONT) && yarn i18n:extract
|
cd $(PATH_FRONT) && yarn i18n:extract
|
||||||
@@ -314,7 +342,7 @@ start-tilt: ## start the kubernetes cluster using kind
|
|||||||
tilt up -f ./bin/Tiltfile
|
tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
VERSION_TYPE ?= minor
|
bump-packages-version: VERSION_TYPE ?= minor
|
||||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
|
|||||||
221
README.md
221
README.md
@@ -1,86 +1,213 @@
|
|||||||
# Impress
|
<p align="center">
|
||||||
|
<a href="https://github.com/suitenumerique/docs">
|
||||||
|
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<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="GitHub closed issues" 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
|
||||||
|
</a> - <a href="/docs/">
|
||||||
|
Documentation
|
||||||
|
</a> - <a href="#getting-started-">
|
||||||
|
Getting started
|
||||||
|
</a> - <a href="mailto:docs@numerique.gouv.fr">
|
||||||
|
Reach out
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
|
# La Suite Docs : Collaborative Text Editing
|
||||||
|
Docs, where your notes can become knowledge through live collaboration.
|
||||||
|
|
||||||
Impress is built on top of [Django Rest
|
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||||
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
|
|
||||||
|
|
||||||
## Getting started
|
## Why use Docs ❓
|
||||||
|
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||||
|
|
||||||
### Prerequisite
|
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
|
||||||
|
|
||||||
Make sure you have a recent version of Docker and [Docker
|
### Write
|
||||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
* 😌 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!
|
||||||
|
|
||||||
```bash
|
### 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
|
||||||
|
🚀 Docs is easy to install on your own servers
|
||||||
|
|
||||||
|
Available methods: Helm chart, Nix package
|
||||||
|
|
||||||
|
In the works: Docker Compose, YunoHost
|
||||||
|
|
||||||
|
⚠️ 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.
|
||||||
|
|
||||||
|
## Getting started 🔧
|
||||||
|
|
||||||
|
### Test it
|
||||||
|
|
||||||
|
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 Docs locally
|
||||||
|
|
||||||
|
> ⚠️ 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, then type:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
$ docker -v
|
$ docker -v
|
||||||
Docker version 20.10.2, build 2291f61
|
|
||||||
|
|
||||||
$ docker compose -v
|
Docker version 20.10.2, build 2291f61
|
||||||
docker compose version 1.27.4, build 40524192
|
|
||||||
|
$ docker compose version
|
||||||
|
|
||||||
|
Docker Compose version v2.32.4
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
|
||||||
> avoided by assigning your user to the `docker` group.
|
|
||||||
|
|
||||||
### Project bootstrap
|
**Project bootstrap**
|
||||||
|
|
||||||
The easiest way to start working on the project is to use GNU Make:
|
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
|
||||||
|
|
||||||
```bash
|
```shellscript
|
||||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
This command builds the `app` container, 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.
|
||||||
You will be prompted to log in, the default credentials are:
|
|
||||||
```bash
|
|
||||||
username: impress
|
|
||||||
password: impress
|
|
||||||
```
|
|
||||||
---
|
|
||||||
|
|
||||||
This command builds the `app` container, installs dependencies, performs
|
|
||||||
database migrations and compile translations. It's a good idea to use this
|
|
||||||
command each time you are pulling code from the project repository to avoid
|
|
||||||
dependency-releated or migration-releated issues.
|
|
||||||
|
|
||||||
Your Docker services should now be up and running 🎉
|
Your Docker services should now be up and running 🎉
|
||||||
|
|
||||||
Note that if you need to run them afterwards, you can use the eponym Make rule:
|
You can access to the project by going to <http://localhost:3000>.
|
||||||
|
|
||||||
```bash
|
You will be prompted to log in. The default credentials are:
|
||||||
$ make run-frontend-dev
|
|
||||||
|
```
|
||||||
|
username: impress
|
||||||
|
password: impress
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding content
|
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||||
|
|
||||||
You can create a basic demo site by running:
|
```shellscript
|
||||||
|
$ make run
|
||||||
|
```
|
||||||
|
|
||||||
$ make demo
|
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
|
||||||
|
|
||||||
Finally, you can check all available Make rules using:
|
To do so, install the frontend dependencies with the following command:
|
||||||
|
|
||||||
```bash
|
```shellscript
|
||||||
|
$ make frontend-development-install
|
||||||
|
```
|
||||||
|
|
||||||
|
And run the frontend locally in development mode with the following command:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make run-frontend-development
|
||||||
|
```
|
||||||
|
|
||||||
|
To start all the services, except the frontend container, you can use the following command:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make run-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding content**
|
||||||
|
|
||||||
|
You can create a basic demo site by running this command:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, you can check all available Make rules using this command:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
$ make help
|
$ make help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Django admin
|
**Django admin**
|
||||||
|
|
||||||
You can access the Django admin site at
|
You can access the Django admin site at:
|
||||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
|
||||||
|
<http://localhost:8071/admin>.
|
||||||
|
|
||||||
You first need to create a superuser account:
|
You first need to create a superuser account:
|
||||||
|
|
||||||
```bash
|
```shellscript
|
||||||
$ make superuser
|
$ make superuser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Feedback 🙋♂️🙋♀️
|
||||||
|
|
||||||
This project is intended to be community-driven, so please, do not hesitate to
|
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).
|
||||||
get in touch if you have any question related to our implementation or design
|
|
||||||
decisions.
|
|
||||||
|
|
||||||
## License
|
## Roadmap
|
||||||
|
|
||||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||||
|
|
||||||
|
## Licence 📝
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Contributing 🙌
|
||||||
|
|
||||||
|
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Directory structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
docs
|
||||||
|
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
|
||||||
|
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
|
||||||
|
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
|
||||||
|
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
|
||||||
|
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
|
||||||
|
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
|
||||||
|
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
|
||||||
|
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits ❤️
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
<img src="/docs/assets/europe_opensource.png" width="50%"/>
|
||||||
|
</p>
|
||||||
|
|||||||
23
SECURITY.md
Normal file
23
SECURITY.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Security is very important to us.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Vulnerability disclosure policy
|
||||||
|
|
||||||
|
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||||
|
|
||||||
|
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||||
|
release).
|
||||||
|
2. In the release notes, we will include the identification numbers from the
|
||||||
|
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||||
|
and Exposures (CVE) identifier for the vulnerability.
|
||||||
|
3. Once this grace period has passed, we will publish the vulnerability.
|
||||||
|
|
||||||
|
By adhering to this security policy, we aim to address security concerns
|
||||||
|
effectively and responsibly in our open source software project.
|
||||||
26
UPGRADE.md
26
UPGRADE.md
@@ -15,3 +15,29 @@ the following command inside your docker container:
|
|||||||
(Note : in your development environment, you can `make migrate`.)
|
(Note : in your development environment, you can `make migrate`.)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-05-22
|
||||||
|
|
||||||
|
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/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`)
|
||||||
|
The authentication is now managed directly from the yProvider server.
|
||||||
|
You must remove the annotation `nginx.ingress.kubernetes.io/auth-url` from the `ingressCollaborationWS`.
|
||||||
|
|
||||||
|
This means as well that the yProvider server must be able to access the Django server.
|
||||||
|
To do so, you must set the `COLLABORATION_BACKEND_BASE_URL` environment variable to the `yProvider`
|
||||||
|
service.
|
||||||
|
|
||||||
|
## [2.2.0] - 2025-02-10
|
||||||
|
|
||||||
|
- AI features are now limited to users who are authenticated. Before this release, even anonymous
|
||||||
|
users who gained editor access on a document with link reach used to get AI feature.
|
||||||
|
IF you want anonymous users to keep access on AI features, you must now define the
|
||||||
|
`AI_ALLOW_REACH_FROM` setting to "public".
|
||||||
|
|||||||
16
bin/Tiltfile
16
bin/Tiltfile
@@ -20,7 +20,7 @@ docker_build(
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/impress-y-provider:latest',
|
'localhost:5001/impress-y-provider:latest',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='../src/frontend/Dockerfile',
|
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
|
||||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||||
target = 'y-provider',
|
target = 'y-provider',
|
||||||
live_update=[
|
live_update=[
|
||||||
@@ -39,7 +39,19 @@ docker_build(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
docker_build(
|
||||||
|
'localhost:5001/impress-mcp-server:latest',
|
||||||
|
context='../src/mcp_server',
|
||||||
|
dockerfile='../src/mcp_server/Dockerfile',
|
||||||
|
)
|
||||||
|
|
||||||
|
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
|
||||||
|
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
|
||||||
|
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
|
||||||
|
|
||||||
|
# helmfile in docker mount the current working directory and the helmfile.yaml
|
||||||
|
# requires the keycloak config in another directory
|
||||||
|
k8s_yaml(local('cd .. && helmfile -n impress -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ UNSET_USER=0
|
|||||||
|
|
||||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||||
COMPOSE_PROJECT="impress"
|
|
||||||
|
|
||||||
|
|
||||||
# _set_user: set (or unset) default user id used to run docker commands
|
# _set_user: set (or unset) default user id used to run docker commands
|
||||||
@@ -40,9 +39,8 @@ function _set_user() {
|
|||||||
# ARGS : docker compose command arguments
|
# ARGS : docker compose command arguments
|
||||||
function _docker_compose() {
|
function _docker_compose() {
|
||||||
|
|
||||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
-p "${COMPOSE_PROJECT}" \
|
|
||||||
-f "${COMPOSE_FILE}" \
|
-f "${COMPOSE_FILE}" \
|
||||||
--project-directory "${REPO_DIR}" \
|
--project-directory "${REPO_DIR}" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -1,103 +1,2 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -o errexit
|
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress
|
||||||
|
|
||||||
CURRENT_DIR=$(pwd)
|
|
||||||
|
|
||||||
echo "0. Create ca"
|
|
||||||
# 0. Create ca
|
|
||||||
mkcert -install
|
|
||||||
cd /tmp
|
|
||||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
|
||||||
cd $CURRENT_DIR
|
|
||||||
|
|
||||||
echo "1. Create registry container unless it already exists"
|
|
||||||
# 1. Create registry container unless it already exists
|
|
||||||
reg_name='kind-registry'
|
|
||||||
reg_port='5001'
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
|
||||||
docker run \
|
|
||||||
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
|
||||||
registry:2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
|
||||||
# 2. Create kind cluster with containerd registry config dir enabled
|
|
||||||
# TODO: kind will eventually enable this by default and this patch will
|
|
||||||
# be unnecessary.
|
|
||||||
#
|
|
||||||
# See:
|
|
||||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
|
||||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
|
||||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
|
||||||
cat <<EOF | kind create cluster --config=-
|
|
||||||
kind: Cluster
|
|
||||||
apiVersion: kind.x-k8s.io/v1alpha4
|
|
||||||
containerdConfigPatches:
|
|
||||||
- |-
|
|
||||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
|
||||||
config_path = "/etc/containerd/certs.d"
|
|
||||||
nodes:
|
|
||||||
- role: control-plane
|
|
||||||
image: kindest/node:v1.27.3
|
|
||||||
kubeadmConfigPatches:
|
|
||||||
- |
|
|
||||||
kind: InitConfiguration
|
|
||||||
nodeRegistration:
|
|
||||||
kubeletExtraArgs:
|
|
||||||
node-labels: "ingress-ready=true"
|
|
||||||
extraPortMappings:
|
|
||||||
- containerPort: 80
|
|
||||||
hostPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
- containerPort: 443
|
|
||||||
hostPort: 443
|
|
||||||
protocol: TCP
|
|
||||||
- role: worker
|
|
||||||
image: kindest/node:v1.27.3
|
|
||||||
- role: worker
|
|
||||||
image: kindest/node:v1.27.3
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "3. Add the registry config to the nodes"
|
|
||||||
# 3. Add the registry config to the nodes
|
|
||||||
#
|
|
||||||
# This is necessary because localhost resolves to loopback addresses that are
|
|
||||||
# network-namespace local.
|
|
||||||
# In other words: localhost in the container is not localhost on the host.
|
|
||||||
#
|
|
||||||
# We want a consistent name that works from both ends, so we tell containerd to
|
|
||||||
# alias localhost:${reg_port} to the registry container when pulling images
|
|
||||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
|
||||||
for node in $(kind get nodes); do
|
|
||||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
|
||||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
|
||||||
[host."http://${reg_name}:5000"]
|
|
||||||
EOF
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "4. Connect the registry to the cluster network if not already connected"
|
|
||||||
# 4. Connect the registry to the cluster network if not already connected
|
|
||||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
|
||||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
|
||||||
docker network connect "kind" "${reg_name}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "5. Document the local registry"
|
|
||||||
# 5. Document the local registry
|
|
||||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
|
||||||
cat <<EOF | kubectl apply -f -
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: local-registry-hosting
|
|
||||||
namespace: kube-public
|
|
||||||
data:
|
|
||||||
localRegistryHosting.v1: |
|
|
||||||
host: "localhost:${reg_port}"
|
|
||||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "6. Install ingress-nginx"
|
|
||||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
|
||||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
|
||||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#
|
#
|
||||||
# Your crowdin's credentials
|
# Your crowdin's credentials
|
||||||
#
|
#
|
||||||
api_token_env: CROWDIN_API_TOKEN
|
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||||
project_id_env: CROWDIN_PROJECT_ID
|
project_id_env: CROWDIN_PROJECT_ID
|
||||||
base_path_env: CROWDIN_BASE_PATH
|
base_path_env: CROWDIN_BASE_PATH
|
||||||
|
|
||||||
@@ -15,11 +15,11 @@ preserve_hierarchy: true
|
|||||||
# Files configuration
|
# Files configuration
|
||||||
#
|
#
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
source : "/backend/locale/django.pot",
|
source : "/backend/locale/django.pot",
|
||||||
dest: "/backend-impress.pot",
|
dest: "/backend-impress.pot",
|
||||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
||||||
dest: "/frontend-impress.json",
|
dest: "/frontend-impress.json",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
name: docs
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgresql:
|
postgresql:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
ports:
|
ports:
|
||||||
@@ -23,6 +30,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '9000:9000'
|
- '9000:9000'
|
||||||
- '9001:9001'
|
- '9001:9001'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 300
|
||||||
entrypoint: ""
|
entrypoint: ""
|
||||||
command: minio server --console-address :9001 /data
|
command: minio server --console-address :9001 /data
|
||||||
volumes:
|
volumes:
|
||||||
@@ -31,7 +43,9 @@ services:
|
|||||||
createbuckets:
|
createbuckets:
|
||||||
image: minio/mc
|
image: minio/mc
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
sh -c "
|
sh -c "
|
||||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||||
@@ -59,11 +73,15 @@ services:
|
|||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
postgresql:
|
||||||
- mailcatcher
|
condition: service_healthy
|
||||||
- redis
|
restart: true
|
||||||
- createbuckets
|
mailcatcher:
|
||||||
- nginx
|
condition: service_started
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
createbuckets:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -94,9 +112,13 @@ services:
|
|||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
postgresql:
|
||||||
- redis
|
condition: service_healthy
|
||||||
- minio
|
restart: true
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -117,18 +139,27 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- keycloak
|
app-dev:
|
||||||
|
condition: service_started
|
||||||
|
y-provider:
|
||||||
|
condition: service_started
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
|
||||||
nginx-front:
|
frontend:
|
||||||
image: nginx:1.25
|
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-development
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
|
||||||
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
|
|
||||||
|
|
||||||
dockerize:
|
|
||||||
image: jwilder/dockerize
|
|
||||||
|
|
||||||
crowdin:
|
crowdin:
|
||||||
image: crowdin/cli:3.16.0
|
image: crowdin/cli:3.16.0
|
||||||
@@ -151,33 +182,21 @@ services:
|
|||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/Dockerfile
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- env.d/development/common
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
volumes:
|
|
||||||
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
|
|
||||||
- /home/frontend/servers/y-provider/node_modules/
|
|
||||||
- /home/frontend/servers/y-provider/dist/
|
|
||||||
|
|
||||||
frontend-dev:
|
|
||||||
user: "${DOCKER_USER:-1000}"
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./src/frontend/Dockerfile
|
|
||||||
target: impress-dev
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./src/frontend/apps/impress:/home/frontend/apps/impress
|
|
||||||
- /home/frontend/node_modules/
|
|
||||||
depends_on:
|
|
||||||
- y-provider
|
|
||||||
- celery-dev
|
|
||||||
|
|
||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
@@ -196,6 +215,13 @@ services:
|
|||||||
- --hostname-admin-url=http://localhost:8083/
|
- --hostname-admin-url=http://localhost:8083/
|
||||||
- --hostname-strict=false
|
- --hostname-strict=false
|
||||||
- --hostname-strict-https=false
|
- --hostname-strict-https=false
|
||||||
|
- --health-enabled=true
|
||||||
|
- --metrics-enabled=true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
environment:
|
environment:
|
||||||
KEYCLOAK_ADMIN: admin
|
KEYCLOAK_ADMIN: admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
@@ -209,4 +235,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- kc_postgresql
|
kc_postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
# Proxy auth for media
|
||||||
location /media/ {
|
location /media/ {
|
||||||
# Auth request configuration
|
# Auth request configuration
|
||||||
auth_request /auth;
|
auth_request /media-auth;
|
||||||
auth_request_set $authHeader $upstream_http_authorization;
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
@@ -19,10 +20,12 @@ server {
|
|||||||
# Get resource from Minio
|
# Get resource from Minio
|
||||||
proxy_pass http://minio:9000/impress-media-storage/;
|
proxy_pass http://minio:9000/impress-media-storage/;
|
||||||
proxy_set_header Host minio:9000;
|
proxy_set_header Host minio:9000;
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /auth {
|
location /media-auth {
|
||||||
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
|
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -39,5 +42,11 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Increase proxy buffer size to allow keycloak to send large
|
||||||
|
# header responses when a user is created.
|
||||||
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
193
docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md
Normal file
193
docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
## Decision TLDR;
|
||||||
|
|
||||||
|
We will use Yjs a CRDT-based library for the collaborative editing of the documents.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend.
|
||||||
|
|
||||||
|
## Considered alternatives
|
||||||
|
|
||||||
|
### ProseMirror
|
||||||
|
|
||||||
|
A robust toolkit for building rich-text editors with collaboration capabilities.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Mature ecosystem | Complex integration with Django |
|
||||||
|
| Rich text editing features | Steeper learning curve |
|
||||||
|
| Used by major companies | More complex to implement offline support |
|
||||||
|
| Large community | |
|
||||||
|
|
||||||
|
### ShareDB
|
||||||
|
|
||||||
|
Real-time database backend based on Operational Transformation.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Battle-tested in production | Complex setup required |
|
||||||
|
| Strong consistency model | Requires specific backend architecture |
|
||||||
|
| Good documentation | Less flexible with different backends |
|
||||||
|
| | Higher latency compared to CRDTs |
|
||||||
|
|
||||||
|
### Convergence
|
||||||
|
|
||||||
|
Complete enterprise solution for real-time collaboration.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Full-featured solution | Commercial licensing |
|
||||||
|
| Built-in presence features | Less community support |
|
||||||
|
| Enterprise support | More expensive |
|
||||||
|
| Good offline support | Overkill for basic needs |
|
||||||
|
|
||||||
|
### CRDT-based Solutions Comparison
|
||||||
|
|
||||||
|
A CRDT-based library specifically designed for real-time collaboration.
|
||||||
|
|
||||||
|
| Category | Pros | Cons |
|
||||||
|
|----------|------|------|
|
||||||
|
| Technical Implementation | • Native real-time collaboration<br>• No central conflict resolution needed<br>• Works well with Django backend<br>• Automatic state synchronization | • Learning curve for CRDT concepts<br>• More complex initial setup<br>• Additional metadata overhead |
|
||||||
|
| User Experience | • Instant local updates<br>• Works offline by default<br>• Low latency<br>• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies<br>• UI must handle temporary conflicts |
|
||||||
|
| Performance | • Excellent scaling with multiple users<br>• Reduced server load<br>• Efficient network usage<br>• Good memory optimization (especially Yjs) | • Slightly higher memory usage<br>• Initial state sync can be larger |
|
||||||
|
| Development | • No need to build conflict resolution<br>• Simple integration with text editors<br>• Future-proof architecture | • Team needs to learn new concepts<br>• Fewer ready-made solutions<br>• May need to build some features from scratch |
|
||||||
|
| Maintenance | • Less server infrastructure<br>• Simpler deployment<br>• Fewer points of failure | • Debugging can be more complex<br>• State management requires careful handling |
|
||||||
|
| Business Impact | • Better offline support for users<br>• Scales well as user base grows<br>• No licensing costs (with Yjs) | • Initial development time might be longer<br>• Team training required |
|
||||||
|
|
||||||
|
#### Yjs
|
||||||
|
- **Type**: State-based CRDT
|
||||||
|
- **Implementation**: JavaScript/TypeScript
|
||||||
|
- **Features**:
|
||||||
|
- Rich text collaboration
|
||||||
|
- Shared types (Array, Map, XML)
|
||||||
|
- Binary encoding
|
||||||
|
- P2P support
|
||||||
|
- **Performance**: Excellent for text editing
|
||||||
|
- **Memory Usage**: Optimized
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
#### Automerge
|
||||||
|
- **Type**: Operation-based CRDT
|
||||||
|
- **Implementation**: JavaScript/Rust
|
||||||
|
- **Features**:
|
||||||
|
- JSON-like data structures
|
||||||
|
- Change history
|
||||||
|
- Undo/Redo
|
||||||
|
- Binary format
|
||||||
|
- **Performance**: Good, with Rust backend
|
||||||
|
- **Memory Usage**: Higher than Yjs
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
#### Legion
|
||||||
|
- **Type**: State-based CRDT
|
||||||
|
- **Implementation**: Rust with JS bindings
|
||||||
|
- **Features**:
|
||||||
|
- High performance
|
||||||
|
- Memory efficient
|
||||||
|
- Binary protocol
|
||||||
|
- **Performance**: Excellent
|
||||||
|
- **Memory Usage**: Very efficient
|
||||||
|
- **License**: Apache 2.0
|
||||||
|
|
||||||
|
#### Diamond Types
|
||||||
|
- **Type**: Operation-based CRDT
|
||||||
|
- **Implementation**: TypeScript
|
||||||
|
- **Features**:
|
||||||
|
- Specialized for text
|
||||||
|
- Small memory footprint
|
||||||
|
- Simple API
|
||||||
|
- **Performance**: Good for text
|
||||||
|
- **Memory Usage**: Efficient
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
Comparison Table:
|
||||||
|
|
||||||
|
| Feature | Yjs | Automerge | Legion | Diamond Types |
|
||||||
|
|---------|-----|-----------|--------|---------------|
|
||||||
|
| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent |
|
||||||
|
| Structured Data | ✅ | ✅ | ✅ | ⚠️ |
|
||||||
|
| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High |
|
||||||
|
| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ |
|
||||||
|
| Maturity | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||||
|
| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small |
|
||||||
|
| Documentation | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||||
|
| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited |
|
||||||
|
|
||||||
|
Key Differences:
|
||||||
|
1. **Implementation Approach**:
|
||||||
|
- Yjs: Optimized for text and rich-text editing
|
||||||
|
- Automerge: General-purpose JSON CRDT
|
||||||
|
- Legion: Performance-focused with Rust
|
||||||
|
- Diamond Types: Specialized for text collaboration
|
||||||
|
|
||||||
|
2. **Performance Characteristics**:
|
||||||
|
- Yjs: Best for text editing scenarios
|
||||||
|
- Automerge: Good all-around performance
|
||||||
|
- Legion: Excellent raw performance
|
||||||
|
- Diamond Types: Optimized for text
|
||||||
|
|
||||||
|
3. **Ecosystem Integration**:
|
||||||
|
- Yjs: Wide range of integrations
|
||||||
|
- Automerge: Good JavaScript ecosystem
|
||||||
|
- Legion: Limited but growing
|
||||||
|
- Diamond Types: Focused on text editors
|
||||||
|
|
||||||
|
This analysis reinforces our choice of Yjs for the CRDT-based option as it provides:
|
||||||
|
- Best-in-class text editing performance
|
||||||
|
- Mature ecosystem
|
||||||
|
- Active community
|
||||||
|
- Excellent documentation
|
||||||
|
- Wide range of backend options
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
After evaluating the alternatives, we choose Yjs for the following reasons:
|
||||||
|
|
||||||
|
1. **Technical Fit:**
|
||||||
|
- Native CRDT support ensures reliable collaboration
|
||||||
|
- Excellent offline capabilities
|
||||||
|
- Good performance characteristics
|
||||||
|
- Flexible backend integration options
|
||||||
|
|
||||||
|
2. **Project Requirements Match:**
|
||||||
|
- Easy integration with our Django backend
|
||||||
|
- Supports our core collaborative features
|
||||||
|
- Manageable learning curve for the team
|
||||||
|
|
||||||
|
3. **Community & Support:**
|
||||||
|
- Active development
|
||||||
|
- Growing community
|
||||||
|
- Good documentation
|
||||||
|
- Open source with MIT license
|
||||||
|
|
||||||
|
### Comparison of Key Features:
|
||||||
|
|
||||||
|
| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence |
|
||||||
|
|---------|-----|-------------|----------|-------------|
|
||||||
|
| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ |
|
||||||
|
| Django Integration | Easy | Complex | Complex | Moderate |
|
||||||
|
| Learning Curve | Medium | High | High | Medium |
|
||||||
|
| Cost | Free | Free | Free | Paid |
|
||||||
|
| Community Size | Growing | Large | Medium | Small |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Simplified implementation of real-time collaboration
|
||||||
|
- Good developer experience
|
||||||
|
- Future-proof technology choice
|
||||||
|
- No licensing costs
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Team needs to learn CRDT concepts
|
||||||
|
- Newer technology compared to alternatives
|
||||||
|
- May need to build some features available out-of-the-box in other solutions
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
- Community support might not grow as expected
|
||||||
|
- May discover limitations as we scale
|
||||||
19
docs/architecture.md
Normal file
19
docs/architecture.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Global system architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
User -- HTTP --> Front("Frontend (NextJS SPA)")
|
||||||
|
Front -- REST API --> Back("Backend (Django)")
|
||||||
|
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
|
||||||
|
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
|
||||||
|
Back -- REST API --> Yserver
|
||||||
|
Back --> DB("Database (PostgreSQL)")
|
||||||
|
Back <--> Celery --> DB
|
||||||
|
Back ----> S3("Minio (S3)")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture decision records
|
||||||
|
|
||||||
|
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
|
||||||
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/docs-logo.png
Normal file
BIN
docs/assets/docs-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 MiB |
BIN
docs/assets/europe_opensource.png
Normal file
BIN
docs/assets/europe_opensource.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 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 |
BIN
docs/assets/logo.png
Normal file
BIN
docs/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
143
docs/env.md
Normal file
143
docs/env.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Docs variables
|
||||||
|
|
||||||
|
Here we describe all environment variables that can be set for the docs application.
|
||||||
|
|
||||||
|
## impress-backend container
|
||||||
|
|
||||||
|
These are the environment variables you can set for the `impress-backend` container.
|
||||||
|
|
||||||
|
| 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 address 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 | |
|
||||||
|
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||||
|
| 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 | |
|
||||||
|
| 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 | {} |
|
||||||
|
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||||
|
| 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 | Authorization 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 parameters | {} |
|
||||||
|
| 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 duplicate emails | false |
|
||||||
|
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
||||||
|
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||||
|
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||||
|
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||||
|
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||||
|
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||||
|
| AI_MODEL | AI Model to use | |
|
||||||
|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||||
|
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||||
|
| 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 |
|
||||||
|
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||||
|
| 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",} |
|
||||||
|
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||||
|
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||||
|
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| 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`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||||
|
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
|
||||||
|
|
||||||
|
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||||
|
|
||||||
|
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||||
|
|
||||||
163
docs/examples/impress.values.yaml
Normal file
163
docs/examples/impress.values.yaml
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
image:
|
||||||
|
repository: lasuite/impress-backend
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
replicas: 1
|
||||||
|
envVars:
|
||||||
|
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
|
||||||
|
COLLABORATION_SERVER_SECRET: my-secret
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
|
||||||
|
DJANGO_CONFIGURATION: Feature
|
||||||
|
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
|
||||||
|
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
|
||||||
|
DJANGO_SECRET_KEY: AgoodOrAbadKey
|
||||||
|
DJANGO_SETTINGS_MODULE: impress.settings
|
||||||
|
DJANGO_SUPERUSER_PASSWORD: admin
|
||||||
|
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||||
|
DJANGO_EMAIL_PORT: 1025
|
||||||
|
DJANGO_EMAIL_USE_SSL: False
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP: INFO
|
||||||
|
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||||
|
OIDC_RP_CLIENT_ID: impress
|
||||||
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
|
OIDC_RP_SCOPES: "openid email"
|
||||||
|
OIDC_VERIFY_SSL: False
|
||||||
|
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||||
|
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||||
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
|
||||||
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
|
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||||
|
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
|
||||||
|
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||||
|
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
|
||||||
|
DB_HOST: postgresql
|
||||||
|
DB_NAME: impress
|
||||||
|
DB_USER: dinum
|
||||||
|
DB_PASSWORD: pass
|
||||||
|
DB_PORT: 5432
|
||||||
|
POSTGRES_DB: impress
|
||||||
|
POSTGRES_USER: dinum
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
|
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||||
|
AWS_S3_ACCESS_KEY_ID: root
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
|
AWS_STORAGE_BUCKET_NAME: impress-media-storage
|
||||||
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
|
||||||
|
Y_PROVIDER_API_KEY: my-secret
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
command:
|
||||||
|
- "/bin/sh"
|
||||||
|
- "-c"
|
||||||
|
- |
|
||||||
|
python manage.py migrate --no-input &&
|
||||||
|
python manage.py create_demo --force
|
||||||
|
restartPolicy: Never
|
||||||
|
|
||||||
|
command:
|
||||||
|
- "gunicorn"
|
||||||
|
- "-c"
|
||||||
|
- "/usr/local/etc/gunicorn/impress.py"
|
||||||
|
- "impress.wsgi:application"
|
||||||
|
- "--reload"
|
||||||
|
|
||||||
|
createsuperuser:
|
||||||
|
command:
|
||||||
|
- "/bin/sh"
|
||||||
|
- "-c"
|
||||||
|
- |
|
||||||
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
|
restartPolicy: Never
|
||||||
|
|
||||||
|
# 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
|
||||||
|
subPath: cacert.pem
|
||||||
|
|
||||||
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
|
extraVolumes:
|
||||||
|
- name: certs
|
||||||
|
configMap:
|
||||||
|
name: certifi
|
||||||
|
items:
|
||||||
|
- key: cacert.pem
|
||||||
|
path: cacert.pem
|
||||||
|
frontend:
|
||||||
|
envVars:
|
||||||
|
PORT: 8080
|
||||||
|
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: lasuite/impress-frontend
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
yProvider:
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: lasuite/impress-y-provider
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
envVars:
|
||||||
|
COLLABORATION_LOGGING: true
|
||||||
|
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||||
|
COLLABORATION_SERVER_SECRET: my-secret
|
||||||
|
Y_PROVIDER_API_KEY: my-secret
|
||||||
|
|
||||||
|
posthog:
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
ingressAssets:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
ingressCollaborationWS:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
||||||
|
|
||||||
|
ingressCollaborationApi:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
ingressAdmin:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
ingressMedia:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
|
||||||
|
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||||
|
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
|
||||||
|
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
|
||||||
|
|
||||||
|
serviceMedia:
|
||||||
|
host: minio.impress.svc.cluster.local
|
||||||
|
port: 9000
|
||||||
|
|
||||||
2299
docs/examples/keycloak.values.yaml
Normal file
2299
docs/examples/keycloak.values.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/examples/minio.values.yaml
Normal file
8
docs/examples/minio.values.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
auth:
|
||||||
|
rootUser: root
|
||||||
|
rootPassword: password
|
||||||
|
provisioning:
|
||||||
|
enabled: true
|
||||||
|
buckets:
|
||||||
|
- name: impress-media-storage
|
||||||
|
versioning: true
|
||||||
7
docs/examples/postgresql.values.yaml
Normal file
7
docs/examples/postgresql.values.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
auth:
|
||||||
|
username: dinum
|
||||||
|
password: pass
|
||||||
|
database: impress
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
autoGenerated: true
|
||||||
4
docs/examples/redis.values.yaml
Normal file
4
docs/examples/redis.values.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
auth:
|
||||||
|
password: pass
|
||||||
|
architecture: standalone
|
||||||
|
|
||||||
230
docs/installation.md
Normal file
230
docs/installation.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Installation on a k8s cluster
|
||||||
|
|
||||||
|
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- k8s cluster with an nginx-ingress controller
|
||||||
|
- an OIDC provider (if you don't have one, we provide an example)
|
||||||
|
- a PostgreSQL server (if you don't have one, we provide an example)
|
||||||
|
- a Memcached server (if you don't have one, we provide an example)
|
||||||
|
- a S3 bucket (if you don't have one, we provide an example)
|
||||||
|
|
||||||
|
### Test cluster
|
||||||
|
|
||||||
|
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||||
|
|
||||||
|
To be able to use the script, you need to install:
|
||||||
|
|
||||||
|
- Docker (https://docs.docker.com/desktop/)
|
||||||
|
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||||
|
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||||
|
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||||
|
|
||||||
|
```
|
||||||
|
./bin/start-kind.sh
|
||||||
|
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||||
|
Dload Upload Total Spent Left Speed
|
||||||
|
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
|
||||||
|
0. Create ca
|
||||||
|
The local CA is already installed in the system trust store! 👍
|
||||||
|
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||||
|
|
||||||
|
|
||||||
|
Created a new certificate valid for the following names 📜
|
||||||
|
- "127.0.0.1.nip.io"
|
||||||
|
- "*.127.0.0.1.nip.io"
|
||||||
|
|
||||||
|
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||||
|
|
||||||
|
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||||
|
|
||||||
|
It will expire on 24 March 2027 🗓
|
||||||
|
|
||||||
|
1. Create registry container unless it already exists
|
||||||
|
2. Create kind cluster with containerd registry config dir enabled
|
||||||
|
Creating cluster "suite" ...
|
||||||
|
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||||
|
✓ Preparing nodes 📦
|
||||||
|
✓ Writing configuration 📜
|
||||||
|
✓ Starting control-plane 🕹️
|
||||||
|
✓ Installing CNI 🔌
|
||||||
|
✓ Installing StorageClass 💾
|
||||||
|
Set kubectl context to "kind-suite"
|
||||||
|
You can now use your cluster with:
|
||||||
|
|
||||||
|
kubectl cluster-info --context kind-suite
|
||||||
|
|
||||||
|
Thanks for using kind! 😊
|
||||||
|
3. Add the registry config to the nodes
|
||||||
|
4. Connect the registry to the cluster network if not already connected
|
||||||
|
5. Document the local registry
|
||||||
|
configmap/local-registry-hosting created
|
||||||
|
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||||
|
configmap/coredns configured
|
||||||
|
deployment.apps/coredns restarted
|
||||||
|
6. Install ingress-nginx
|
||||||
|
namespace/ingress-nginx created
|
||||||
|
serviceaccount/ingress-nginx created
|
||||||
|
serviceaccount/ingress-nginx-admission created
|
||||||
|
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||||
|
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||||
|
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||||
|
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||||
|
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||||
|
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||||
|
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||||
|
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||||
|
configmap/ingress-nginx-controller created
|
||||||
|
service/ingress-nginx-controller created
|
||||||
|
service/ingress-nginx-controller-admission created
|
||||||
|
deployment.apps/ingress-nginx-controller created
|
||||||
|
job.batch/ingress-nginx-admission-create created
|
||||||
|
job.batch/ingress-nginx-admission-patch created
|
||||||
|
ingressclass.networking.k8s.io/nginx created
|
||||||
|
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||||
|
secret/mkcert created
|
||||||
|
deployment.apps/ingress-nginx-controller patched
|
||||||
|
7. Setup namespace
|
||||||
|
namespace/impress created
|
||||||
|
Context "kind-suite" modified.
|
||||||
|
secret/mkcert created
|
||||||
|
$ kubectl -n ingress-nginx get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
|
||||||
|
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
||||||
|
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
||||||
|
```
|
||||||
|
|
||||||
|
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||||
|
|
||||||
|
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||||
|
|
||||||
|
## Preparation
|
||||||
|
|
||||||
|
### What do you use to authenticate your users?
|
||||||
|
|
||||||
|
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||||
|
|
||||||
|
```
|
||||||
|
$ kubectl create namespace impress
|
||||||
|
$ kubectl config set-context --current --namespace=impress
|
||||||
|
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||||
|
$ #wait until
|
||||||
|
$ kubectl get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
keycloak-0 1/1 Running 0 6m48s
|
||||||
|
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||||
|
```
|
||||||
|
|
||||||
|
From here the important information you will need are:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
||||||
|
OIDC_RP_CLIENT_ID: impress
|
||||||
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
|
OIDC_RP_SCOPES: "openid email"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can find these values in **examples/keycloak.values.yaml**
|
||||||
|
|
||||||
|
### Find redis server connection values
|
||||||
|
|
||||||
|
Docs needs a redis so we start by deploying one:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||||
|
$ kubectl get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
keycloak-0 1/1 Running 0 26m
|
||||||
|
keycloak-postgresql-0 1/1 Running 0 26m
|
||||||
|
redis-master-0 1/1 Running 0 35s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||||
|
$ kubectl get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
keycloak-0 1/1 Running 0 28m
|
||||||
|
keycloak-postgresql-0 1/1 Running 0 28m
|
||||||
|
postgresql-0 1/1 Running 0 14m
|
||||||
|
redis-master-0 1/1 Running 0 42s
|
||||||
|
```
|
||||||
|
|
||||||
|
From here the important information you will need are:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
DB_HOST: postgres-postgresql
|
||||||
|
DB_NAME: impress
|
||||||
|
DB_USER: dinum
|
||||||
|
DB_PASSWORD: pass
|
||||||
|
DB_PORT: 5432
|
||||||
|
POSTGRES_DB: impress
|
||||||
|
POSTGRES_USER: dinum
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find s3 bucket 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
||||||
|
$ kubectl get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
keycloak-0 1/1 Running 0 38m
|
||||||
|
keycloak-postgresql-0 1/1 Running 0 38m
|
||||||
|
minio-84f5c66895-bbhsk 1/1 Running 0 42s
|
||||||
|
minio-provisioning-2b5sq 0/1 Completed 0 42s
|
||||||
|
postgresql-0 1/1 Running 0 24m
|
||||||
|
redis-master-0 1/1 Running 0 10m
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||||
|
$ helm repo update
|
||||||
|
$ helm install impress impress/docs -f examples/impress.values.yaml
|
||||||
|
$ kubectl get po
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
|
||||||
|
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
|
||||||
|
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
|
||||||
|
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
|
||||||
|
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
|
||||||
|
keycloak-0 1/1 Running 0 48m
|
||||||
|
keycloak-postgresql-0 1/1 Running 0 48m
|
||||||
|
minio-84f5c66895-bbhsk 1/1 Running 0 10m
|
||||||
|
minio-provisioning-2b5sq 0/1 Completed 0 10m
|
||||||
|
postgresql-0 1/1 Running 0 34m
|
||||||
|
redis-master-0 1/1 Running 0 20m
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test your deployment
|
||||||
|
|
||||||
|
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ kubectl get ingress
|
||||||
|
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||||
|
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||||
|
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||||
|
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||||
|
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||||
|
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
|
||||||
|
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||||
56
docs/theming.md
Normal file
56
docs/theming.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Runtime Theming 🎨
|
||||||
|
|
||||||
|
### How to Use
|
||||||
|
|
||||||
|
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
FRONTEND_CSS_URL=http://anything/custom-style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
This feature provides several benefits, including:
|
||||||
|
|
||||||
|
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
|
||||||
|
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
|
||||||
|
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
|
||||||
|
|
||||||
|
### Example Use Case
|
||||||
|
|
||||||
|
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
|
||||||
|
|
||||||
|
```css
|
||||||
|
body {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **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 ⬇️:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
@@ -4,13 +4,21 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
|||||||
DJANGO_SETTINGS_MODULE=impress.settings
|
DJANGO_SETTINGS_MODULE=impress.settings
|
||||||
DJANGO_SUPERUSER_PASSWORD=admin
|
DJANGO_SUPERUSER_PASSWORD=admin
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# Set to DEBUG level for dev only
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
PYTHONPATH=/app
|
PYTHONPATH=/app
|
||||||
|
|
||||||
# impress settings
|
# impress settings
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
DJANGO_EMAIL_HOST="mailcatcher"
|
DJANGO_EMAIL_HOST="mailcatcher"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
|
||||||
DJANGO_EMAIL_PORT=1025
|
DJANGO_EMAIL_PORT=1025
|
||||||
|
|
||||||
# Backend url
|
# Backend url
|
||||||
@@ -21,6 +29,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
|
|||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=impress
|
AWS_S3_ACCESS_KEY_ID=impress
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
|
MEDIA_BASE_URL=http://localhost:8083
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
||||||
@@ -39,3 +48,16 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
|||||||
|
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
|
# AI
|
||||||
|
AI_FEATURE_ENABLED=true
|
||||||
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
AI_API_KEY=password
|
||||||
|
AI_MODEL=llama
|
||||||
|
|
||||||
|
# Collaboration
|
||||||
|
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||||
|
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
||||||
|
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
||||||
|
COLLABORATION_SERVER_SECRET=my-secret
|
||||||
|
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
# For the CI job test-e2e
|
# For the CI job test-e2e
|
||||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
|
||||||
BURST_THROTTLE_RATES="200/minute"
|
BURST_THROTTLE_RATES="200/minute"
|
||||||
|
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
||||||
|
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||||
|
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||||
|
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
CROWDIN_API_TOKEN=Your-Api-Token
|
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
|
||||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||||
CROWDIN_BASE_PATH=/app/src
|
CROWDIN_BASE_PATH=/app/src
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class GitmojiTitle(LineRule):
|
|||||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||||
).json()["gitmojis"]
|
).json()["gitmojis"]
|
||||||
emojis = [item["emoji"] for item in gitmojis]
|
emojis = [item["emoji"] for item in gitmojis]
|
||||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis))
|
||||||
if not re.search(pattern, title):
|
if not re.search(pattern, title):
|
||||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||||
return [RuleViolation(self.id, violation_msg, title)]
|
return [RuleViolation(self.id, violation_msg, title)]
|
||||||
|
|||||||
27
publiccode.yml
Normal file
27
publiccode.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
publiccodeYmlVersion: "2.4.0"
|
||||||
|
name: Docs
|
||||||
|
url: https://github.com/suitenumerique/docs
|
||||||
|
landingURL: https://github.com/suitenumerique/docs
|
||||||
|
creationDate: 2023-12-10
|
||||||
|
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||||
|
usedBy:
|
||||||
|
- Direction interministériel du numérique (DINUM)
|
||||||
|
fundedBy:
|
||||||
|
- name: Direction interministériel du numérique (DINUM)
|
||||||
|
url: https://www.numerique.gouv.fr
|
||||||
|
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
|
||||||
|
softwareType: "standalone/other"
|
||||||
|
description:
|
||||||
|
en:
|
||||||
|
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
|
||||||
|
fr:
|
||||||
|
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
|
||||||
|
legal:
|
||||||
|
license: MIT
|
||||||
|
maintenance:
|
||||||
|
type: internal
|
||||||
|
contacts:
|
||||||
|
- name: "Virgile Deville"
|
||||||
|
email: "virgile.deville@numerique.gouv.fr"
|
||||||
|
- name: "samuel.paccoud"
|
||||||
|
email: "samuel.paccoud@numerique.gouv.fr"
|
||||||
@@ -9,11 +9,31 @@
|
|||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
"matchPackageNames": []
|
"matchPackageNames": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed django versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["Django"],
|
||||||
|
"allowedVersions": "<5.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed redis versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["redis"],
|
||||||
|
"allowedVersions": "<6.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
"matchPackageNames": [
|
||||||
|
"@hocuspocus/provider",
|
||||||
|
"@hocuspocus/server",
|
||||||
|
"eslint",
|
||||||
|
"fetch-mock",
|
||||||
|
"node",
|
||||||
|
"node-fetch",
|
||||||
|
"workbox-webpack-plugin"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
secrets
1
secrets
Submodule secrets deleted from 2643697e5f
0
secu-audit.md
Normal file
0
secu-audit.md
Normal file
@@ -447,10 +447,10 @@ max-bool-expr=5
|
|||||||
max-branches=12
|
max-branches=12
|
||||||
|
|
||||||
# Maximum number of locals for function / method body
|
# Maximum number of locals for function / method body
|
||||||
max-locals=15
|
max-locals=20
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
# Maximum number of parents for a class (see R0901).
|
||||||
max-parents=7
|
max-parents=10
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
# Maximum number of public methods for a class (see R0904).
|
||||||
max-public-methods=20
|
max-public-methods=20
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ from django.contrib import admin
|
|||||||
from django.contrib.auth import admin as auth_admin
|
from django.contrib.auth import admin as auth_admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from treebeard.admin import TreeAdmin
|
||||||
|
from treebeard.forms import movenodeform_factory
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class TemplateAccessInline(admin.TabularInline):
|
class TemplateAccessInline(admin.TabularInline):
|
||||||
"""Inline admin class for template accesses."""
|
"""Inline admin class for template accesses."""
|
||||||
|
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
model = models.TemplateAccess
|
model = models.TemplateAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
@@ -29,7 +33,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
(
|
||||||
|
_("Personal info"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"sub",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"language",
|
||||||
|
"timezone",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
_("Permissions"),
|
_("Permissions"),
|
||||||
{
|
{
|
||||||
@@ -58,6 +74,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"sub",
|
"sub",
|
||||||
|
"full_name",
|
||||||
"admin_email",
|
"admin_email",
|
||||||
"email",
|
"email",
|
||||||
"is_active",
|
"is_active",
|
||||||
@@ -68,9 +85,24 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
ordering = (
|
||||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
"is_active",
|
||||||
search_fields = ("id", "sub", "admin_email", "email")
|
"-is_superuser",
|
||||||
|
"-is_staff",
|
||||||
|
"-is_device",
|
||||||
|
"-updated_at",
|
||||||
|
"full_name",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"sub",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Template)
|
@admin.register(models.Template)
|
||||||
@@ -83,14 +115,49 @@ class TemplateAdmin(admin.ModelAdmin):
|
|||||||
class DocumentAccessInline(admin.TabularInline):
|
class DocumentAccessInline(admin.TabularInline):
|
||||||
"""Inline admin class for template accesses."""
|
"""Inline admin class for template accesses."""
|
||||||
|
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
model = models.DocumentAccess
|
model = models.DocumentAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Document)
|
@admin.register(models.Document)
|
||||||
class DocumentAdmin(admin.ModelAdmin):
|
class DocumentAdmin(TreeAdmin):
|
||||||
"""Document admin interface declaration."""
|
"""Document admin interface declaration."""
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Permissions"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"creator",
|
||||||
|
"link_reach",
|
||||||
|
"link_role",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Tree structure"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"path",
|
||||||
|
"depth",
|
||||||
|
"numchild",
|
||||||
|
"duplicated_from",
|
||||||
|
"attachments",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
form = movenodeform_factory(models.Document)
|
||||||
inlines = (DocumentAccessInline,)
|
inlines = (DocumentAccessInline,)
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
@@ -100,6 +167,16 @@ class DocumentAdmin(admin.ModelAdmin):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"attachments",
|
||||||
|
"creator",
|
||||||
|
"depth",
|
||||||
|
"duplicated_from",
|
||||||
|
"id",
|
||||||
|
"numchild",
|
||||||
|
"path",
|
||||||
|
)
|
||||||
|
search_fields = ("id", "title")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Invitation)
|
@admin.register(models.Invitation)
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ def exception_handler(exc, context):
|
|||||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||||
"""
|
"""
|
||||||
if isinstance(exc, ValidationError):
|
if isinstance(exc, ValidationError):
|
||||||
detail = exc.message_dict
|
detail = None
|
||||||
|
if hasattr(exc, "message_dict"):
|
||||||
if hasattr(exc, "message"):
|
detail = exc.message_dict
|
||||||
|
elif hasattr(exc, "message"):
|
||||||
detail = exc.message
|
detail = exc.message
|
||||||
elif hasattr(exc, "messages"):
|
elif hasattr(exc, "messages"):
|
||||||
detail = exc.messages
|
detail = exc.messages
|
||||||
|
|||||||
108
src/backend/core/api/filters.py
Normal file
108
src/backend/core/api/filters.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""API filters for Impress' core application."""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import django_filters
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
A custom CharFilter that filters on the accent-insensitive value searched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
model = models.Document
|
||||||
|
fields = ["title"]
|
||||||
|
|
||||||
|
|
||||||
|
class ListDocumentFilter(DocumentFilter):
|
||||||
|
"""
|
||||||
|
Custom filter for filtering documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_creator_me = django_filters.BooleanFilter(
|
||||||
|
method="filter_is_creator_me", label=_("Creator is me")
|
||||||
|
)
|
||||||
|
is_favorite = django_filters.BooleanFilter(
|
||||||
|
method="filter_is_favorite", label=_("Favorite")
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Document
|
||||||
|
fields = ["is_creator_me", "is_favorite", "title"]
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def filter_is_creator_me(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter documents based on the `creator` being the current user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- /api/v1.0/documents/?is_creator_me=true
|
||||||
|
→ Filters documents created by the logged-in user
|
||||||
|
- /api/v1.0/documents/?is_creator_me=false
|
||||||
|
→ Filters documents created by other users
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
if value:
|
||||||
|
return queryset.filter(creator=user)
|
||||||
|
|
||||||
|
return queryset.exclude(creator=user)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def filter_is_favorite(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter documents based on whether they are marked as favorite by the current user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- /api/v1.0/documents/?is_favorite=true
|
||||||
|
→ Filters documents marked as favorite by the logged-in user
|
||||||
|
- /api/v1.0/documents/?is_favorite=false
|
||||||
|
→ Filters documents not marked as favorite by the logged-in user
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
return queryset.filter(is_favorite=bool(value))
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Permission handlers for the impress core app."""
|
"""Permission handlers for the impress core app."""
|
||||||
|
|
||||||
from django.core import exceptions
|
from django.core import exceptions
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||||
|
"children": {"GET": "children_list", "POST": "children_create"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +64,38 @@ class IsOwnedOrPublic(IsAuthenticated):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CanCreateInvitationPermission(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Custom permission class to handle permission checks for managing invitations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Ensure the user is authenticated
|
||||||
|
if not (bool(request.auth) or request.user.is_authenticated):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Apply permission checks only for creation (POST requests)
|
||||||
|
if view.action != "create":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if resource_id is passed in the context
|
||||||
|
try:
|
||||||
|
document_id = view.kwargs["resource_id"]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise exceptions.ValidationError(
|
||||||
|
"You must set a document ID in kwargs to manage document invitations."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Check if the user has access to manage invitations (Owner/Admin roles)
|
||||||
|
return DocumentAccess.objects.filter(
|
||||||
|
Q(user=user) | Q(team__in=user.teams),
|
||||||
|
document=document_id,
|
||||||
|
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class AccessPermission(permissions.BasePermission):
|
class AccessPermission(permissions.BasePermission):
|
||||||
"""Permission class for access objects."""
|
"""Permission class for access objects."""
|
||||||
|
|
||||||
@@ -74,3 +111,26 @@ class AccessPermission(permissions.BasePermission):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
return abilities.get(action, False)
|
return abilities.get(action, False)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessPermission(AccessPermission):
|
||||||
|
"""Subclass to handle soft deletion specificities."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""
|
||||||
|
Return a 404 on deleted documents
|
||||||
|
- for which the trashbin cutoff is past
|
||||||
|
- for which the current user is not owner of the document or one of its ancestors
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
deleted_at := obj.ancestors_deleted_at
|
||||||
|
) 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)
|
||||||
|
|
||||||
|
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
return has_permission
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
"""Client serializers for the impress core app."""
|
"""Client serializers for the impress core app."""
|
||||||
|
|
||||||
|
import binascii
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from base64 import b64decode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.functional import lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import magic
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import exceptions, serializers
|
||||||
|
|
||||||
from core import models
|
from core import enums, models, utils
|
||||||
|
from core.services.ai_services import AI_ACTIONS
|
||||||
|
from core.services.converter_services import (
|
||||||
|
ConversionError,
|
||||||
|
YdocConverter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -16,8 +25,28 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email"]
|
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||||
read_only_fields = ["id", "email"]
|
read_only_fields = ["id", "email", "full_name", "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"]
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||||
@@ -69,6 +98,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
|
|||||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
Q(user=user) | Q(team__in=user.teams),
|
||||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||||
|
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||||
).exists():
|
).exists():
|
||||||
raise exceptions.PermissionDenied(
|
raise exceptions.PermissionDenied(
|
||||||
"You are not allowed to manage accesses for this resource."
|
"You are not allowed to manage accesses for this resource."
|
||||||
@@ -110,6 +140,17 @@ class DocumentAccessSerializer(BaseAccessSerializer):
|
|||||||
read_only_fields = ["id", "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):
|
class TemplateAccessSerializer(BaseAccessSerializer):
|
||||||
"""Serialize template accesses."""
|
"""Serialize template accesses."""
|
||||||
|
|
||||||
@@ -120,47 +161,121 @@ class TemplateAccessSerializer(BaseAccessSerializer):
|
|||||||
read_only_fields = ["id", "abilities"]
|
read_only_fields = ["id", "abilities"]
|
||||||
|
|
||||||
|
|
||||||
class BaseResourceSerializer(serializers.ModelSerializer):
|
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize documents."""
|
"""Serialize documents with limited fields for display in lists."""
|
||||||
|
|
||||||
|
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)
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
def get_abilities(self, document) -> dict:
|
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request:
|
|
||||||
return document.get_abilities(request.user)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentSerializer(BaseResourceSerializer):
|
|
||||||
"""Serialize documents."""
|
|
||||||
|
|
||||||
content = serializers.CharField(required=False)
|
|
||||||
accesses = DocumentAccessSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"content",
|
|
||||||
"title",
|
|
||||||
"accesses",
|
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"created_at",
|
||||||
|
"creator",
|
||||||
|
"depth",
|
||||||
|
"excerpt",
|
||||||
|
"is_favorite",
|
||||||
"link_role",
|
"link_role",
|
||||||
"link_reach",
|
"link_reach",
|
||||||
"created_at",
|
"nb_accesses_ancestors",
|
||||||
|
"nb_accesses_direct",
|
||||||
|
"numchild",
|
||||||
|
"path",
|
||||||
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"user_roles",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"accesses",
|
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"created_at",
|
||||||
|
"creator",
|
||||||
|
"depth",
|
||||||
|
"excerpt",
|
||||||
|
"is_favorite",
|
||||||
"link_role",
|
"link_role",
|
||||||
"link_reach",
|
"link_reach",
|
||||||
"created_at",
|
"nb_accesses_ancestors",
|
||||||
|
"nb_accesses_direct",
|
||||||
|
"numchild",
|
||||||
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"user_roles",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_abilities(self, document) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
def get_user_roles(self, document):
|
||||||
|
"""
|
||||||
|
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 []
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSerializer(ListDocumentSerializer):
|
||||||
|
"""Serialize documents with all fields for display in detail views."""
|
||||||
|
|
||||||
|
content = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Document
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"abilities",
|
||||||
|
"content",
|
||||||
|
"created_at",
|
||||||
|
"creator",
|
||||||
|
"depth",
|
||||||
|
"excerpt",
|
||||||
|
"is_favorite",
|
||||||
|
"link_role",
|
||||||
|
"link_reach",
|
||||||
|
"nb_accesses_ancestors",
|
||||||
|
"nb_accesses_direct",
|
||||||
|
"numchild",
|
||||||
|
"path",
|
||||||
|
"title",
|
||||||
|
"updated_at",
|
||||||
|
"user_roles",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"abilities",
|
||||||
|
"created_at",
|
||||||
|
"creator",
|
||||||
|
"depth",
|
||||||
|
"is_favorite",
|
||||||
|
"link_role",
|
||||||
|
"link_reach",
|
||||||
|
"nb_accesses_ancestors",
|
||||||
|
"nb_accesses_direct",
|
||||||
|
"numchild",
|
||||||
|
"path",
|
||||||
|
"updated_at",
|
||||||
|
"user_roles",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
@@ -186,8 +301,165 @@ class DocumentSerializer(BaseResourceSerializer):
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_content(self, value):
|
||||||
|
"""Validate the content field."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
try:
|
||||||
|
b64decode(value, validate=True)
|
||||||
|
except binascii.Error as err:
|
||||||
|
raise serializers.ValidationError("Invalid base64 content.") from err
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Process the content field to extract attachment keys and update the document's
|
||||||
|
"attachments" field for access control.
|
||||||
|
"""
|
||||||
|
content = self.validated_data.get("content", "")
|
||||||
|
extracted_attachments = set(utils.extract_attachments(content))
|
||||||
|
|
||||||
|
existing_attachments = (
|
||||||
|
set(self.instance.attachments or []) if self.instance else set()
|
||||||
|
)
|
||||||
|
new_attachments = extracted_attachments - existing_attachments
|
||||||
|
|
||||||
|
if new_attachments:
|
||||||
|
attachments_documents = (
|
||||||
|
models.Document.objects.filter(
|
||||||
|
attachments__overlap=list(new_attachments)
|
||||||
|
)
|
||||||
|
.only("path", "attachments")
|
||||||
|
.order_by("path")
|
||||||
|
)
|
||||||
|
|
||||||
|
user = self.context["request"].user
|
||||||
|
readable_per_se_paths = (
|
||||||
|
models.Document.objects.readable_per_se(user)
|
||||||
|
.order_by("path")
|
||||||
|
.values_list("path", flat=True)
|
||||||
|
)
|
||||||
|
readable_attachments_paths = utils.filter_descendants(
|
||||||
|
[doc.path for doc in attachments_documents],
|
||||||
|
readable_per_se_paths,
|
||||||
|
skip_sorting=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
readable_attachments = set()
|
||||||
|
for document in attachments_documents:
|
||||||
|
if document.path not in readable_attachments_paths:
|
||||||
|
continue
|
||||||
|
readable_attachments.update(set(document.attachments) & new_attachments)
|
||||||
|
|
||||||
|
# Update attachments with readable keys
|
||||||
|
self.validated_data["attachments"] = list(
|
||||||
|
existing_attachments | readable_attachments
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for creating a document from a server-to-server request.
|
||||||
|
|
||||||
|
Expects 'content' as a markdown string, which is converted to our internal format
|
||||||
|
via a Node.js microservice. The conversion is handled automatically, so third parties
|
||||||
|
only need to provide markdown.
|
||||||
|
|
||||||
|
Both "sub" and "email" are required because the external app calling doesn't know
|
||||||
|
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
|
||||||
|
submitted "email" field and use the email address set on the user account in our database
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Document
|
||||||
|
title = serializers.CharField(required=True)
|
||||||
|
content = serializers.CharField(required=True)
|
||||||
|
# User
|
||||||
|
sub = serializers.CharField(
|
||||||
|
required=True, validators=[models.User.sub_validator], max_length=255
|
||||||
|
)
|
||||||
|
email = serializers.EmailField(required=True)
|
||||||
|
language = serializers.ChoiceField(
|
||||||
|
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
|
||||||
|
)
|
||||||
|
# Invitation
|
||||||
|
message = serializers.CharField(required=False)
|
||||||
|
subject = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create the document and associate it with the user or send an invitation."""
|
||||||
|
language = validated_data.get("language", settings.LANGUAGE_CODE)
|
||||||
|
|
||||||
|
# Get the user on its sub (unique identifier). Default on email if allowed in settings
|
||||||
|
email = validated_data["email"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get_user_by_sub_or_email(
|
||||||
|
validated_data["sub"], email
|
||||||
|
)
|
||||||
|
except models.DuplicateEmailError as err:
|
||||||
|
raise serializers.ValidationError({"email": [err.message]}) from err
|
||||||
|
|
||||||
|
if user:
|
||||||
|
email = user.email
|
||||||
|
language = user.language or language
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_content = YdocConverter().convert_markdown(
|
||||||
|
validated_data["content"]
|
||||||
|
)
|
||||||
|
except ConversionError as err:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"content": ["Could not convert content"]}
|
||||||
|
) from err
|
||||||
|
|
||||||
|
document = models.Document.add_root(
|
||||||
|
title=validated_data["title"],
|
||||||
|
content=document_content,
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Associate the document with the pre-existing user
|
||||||
|
models.DocumentAccess.objects.create(
|
||||||
|
document=document,
|
||||||
|
role=models.RoleChoices.OWNER,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# The user doesn't exist in our database: we need to invite him/her
|
||||||
|
models.Invitation.objects.create(
|
||||||
|
document=document,
|
||||||
|
email=email,
|
||||||
|
role=models.RoleChoices.OWNER,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_email_notification(document, validated_data, email, language)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _send_email_notification(self, document, validated_data, email, language):
|
||||||
|
"""Notify the user about the newly created document."""
|
||||||
|
subject = validated_data.get("subject") or _(
|
||||||
|
"A new document was created on your behalf!"
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"message": validated_data.get("message")
|
||||||
|
or _("You have been granted ownership of a new document:"),
|
||||||
|
"title": subject,
|
||||||
|
}
|
||||||
|
document.send_email(subject, [email], context, language)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
This serializer does not support updates.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Update is not supported for this serializer.")
|
||||||
|
|
||||||
|
|
||||||
|
class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serialize link configuration for documents.
|
Serialize link configuration for documents.
|
||||||
We expose it separately from document in order to simplify and secure access control.
|
We expose it separately from document in order to simplify and secure access control.
|
||||||
@@ -201,6 +473,27 @@ class LinkDocumentSerializer(BaseResourceSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for duplicating a document.
|
||||||
|
Allows specifying whether to keep access permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with_accesses = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""
|
||||||
|
This serializer is not intended to create objects.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("This serializer does not support creation.")
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
This serializer is not intended to update objects.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("This serializer does not support updating.")
|
||||||
|
|
||||||
|
|
||||||
# Suppress the warning about not implementing `create` and `update` methods
|
# Suppress the warning about not implementing `create` and `update` methods
|
||||||
# since we don't use a model and only rely on the serializer for validation
|
# since we don't use a model and only rely on the serializer for validation
|
||||||
# pylint: disable=abstract-method
|
# pylint: disable=abstract-method
|
||||||
@@ -218,20 +511,53 @@ class FileUploadSerializer(serializers.Serializer):
|
|||||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate file type
|
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
||||||
mime_type, _ = mimetypes.guess_type(file.name)
|
|
||||||
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
|
# Read the first few bytes to determine the MIME type accurately
|
||||||
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
|
mime = magic.Magic(mime=True)
|
||||||
raise serializers.ValidationError(
|
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||||
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
|
file.seek(0) # Reset file pointer to the beginning after reading
|
||||||
)
|
|
||||||
|
self.context["is_unsafe"] = (
|
||||||
|
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||||
|
|
||||||
|
# Try guessing a coherent extension from the mimetype
|
||||||
|
if extension_mime_type != magic_mime_type:
|
||||||
|
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
|
||||||
|
# can be) are replaced by the extension we eventually guessed from mimetype.
|
||||||
|
if (extension is None or len(extension) > 5) and guessed_ext:
|
||||||
|
extension = guessed_ext[1:]
|
||||||
|
|
||||||
|
if extension is None:
|
||||||
|
raise serializers.ValidationError("Could not determine file extension.")
|
||||||
|
|
||||||
|
self.context["expected_extension"] = extension
|
||||||
|
self.context["content_type"] = magic_mime_type
|
||||||
|
self.context["file_name"] = file.name
|
||||||
|
|
||||||
return file
|
return file
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Override validate to add the computed extension to validated_data."""
|
||||||
|
attrs["expected_extension"] = self.context["expected_extension"]
|
||||||
|
attrs["is_unsafe"] = self.context["is_unsafe"]
|
||||||
|
attrs["content_type"] = self.context["content_type"]
|
||||||
|
attrs["file_name"] = self.context["file_name"]
|
||||||
|
return attrs
|
||||||
|
|
||||||
class TemplateSerializer(BaseResourceSerializer):
|
|
||||||
|
class TemplateSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize templates."""
|
"""Serialize templates."""
|
||||||
|
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Template
|
model = models.Template
|
||||||
fields = [
|
fields = [
|
||||||
@@ -245,6 +571,13 @@ class TemplateSerializer(BaseResourceSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ["id", "accesses", "abilities"]
|
read_only_fields = ["id", "accesses", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, document) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return document.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method
|
# pylint: disable=abstract-method
|
||||||
class DocumentGenerationSerializer(serializers.Serializer):
|
class DocumentGenerationSerializer(serializers.Serializer):
|
||||||
@@ -299,54 +632,106 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Validate and restrict invitation to new user based on email."""
|
"""Validate invitation data."""
|
||||||
|
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
user = getattr(request, "user", None)
|
user = getattr(request, "user", None)
|
||||||
role = attrs.get("role")
|
|
||||||
|
|
||||||
try:
|
attrs["document_id"] = self.context["resource_id"]
|
||||||
document_id = self.context["resource_id"]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise exceptions.ValidationError(
|
|
||||||
"You must set a document ID in kwargs to create a new document invitation."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not user and user.is_authenticated:
|
# Only set the issuer if the instance is being created
|
||||||
raise exceptions.PermissionDenied(
|
if self.instance is None:
|
||||||
"Anonymous users are not allowed to create invitations."
|
attrs["issuer"] = user
|
||||||
)
|
|
||||||
|
|
||||||
if not models.DocumentAccess.objects.filter(
|
return attrs
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
document=document_id,
|
|
||||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
|
||||||
).exists():
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"You are not allowed to manage invitations for this document."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
def validate_role(self, role):
|
||||||
role == models.RoleChoices.OWNER
|
"""Custom validation for the role field."""
|
||||||
and not models.DocumentAccess.objects.filter(
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
document_id = self.context["resource_id"]
|
||||||
|
|
||||||
|
# If the role is OWNER, check if the user has OWNER access
|
||||||
|
if role == models.RoleChoices.OWNER:
|
||||||
|
if not models.DocumentAccess.objects.filter(
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
Q(user=user) | Q(team__in=user.teams),
|
||||||
document=document_id,
|
document=document_id,
|
||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
).exists()
|
).exists():
|
||||||
):
|
raise serializers.ValidationError(
|
||||||
raise exceptions.PermissionDenied(
|
"Only owners of a document can invite other users as owners."
|
||||||
"Only owners of a document can invite other users as owners."
|
)
|
||||||
)
|
|
||||||
|
|
||||||
attrs["document_id"] = document_id
|
return role
|
||||||
attrs["issuer"] = user
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentVersionSerializer(serializers.Serializer):
|
class VersionFilterSerializer(serializers.Serializer):
|
||||||
"""Serialize Versions."""
|
"""Validate version filters applied to the list endpoint."""
|
||||||
|
|
||||||
etag = serializers.CharField()
|
version_id = serializers.CharField(required=False, allow_blank=True)
|
||||||
is_latest = serializers.BooleanField()
|
page_size = serializers.IntegerField(
|
||||||
last_modified = serializers.DateTimeField()
|
required=False, min_value=1, max_value=50, default=20
|
||||||
version_id = serializers.CharField()
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AITransformSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for AI transform requests."""
|
||||||
|
|
||||||
|
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
|
||||||
|
text = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_text(self, value):
|
||||||
|
"""Ensure the text field is not empty."""
|
||||||
|
|
||||||
|
if len(value.strip()) == 0:
|
||||||
|
raise serializers.ValidationError("Text field cannot be empty.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AITranslateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for AI translate requests."""
|
||||||
|
|
||||||
|
language = serializers.ChoiceField(
|
||||||
|
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
|
||||||
|
)
|
||||||
|
text = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_text(self, value):
|
||||||
|
"""Ensure the text field is not empty."""
|
||||||
|
|
||||||
|
if len(value.strip()) == 0:
|
||||||
|
raise serializers.ValidationError("Text field cannot be empty.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class MoveDocumentSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for validating input data to move a document within the tree structure.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- target_document_id (UUIDField): The ID of the target parent document where the
|
||||||
|
document should be moved. This field is required and must be a valid UUID.
|
||||||
|
- position (ChoiceField): Specifies the position of the document in relation to
|
||||||
|
the target parent's children.
|
||||||
|
Choices:
|
||||||
|
- "first-child": Place the document as the first child of the target parent.
|
||||||
|
- "last-child": Place the document as the last child of the target parent (default).
|
||||||
|
- "left": Place the document as the left sibling of the target parent.
|
||||||
|
- "right": Place the document as the right sibling of the target parent.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input payload for moving a document:
|
||||||
|
{
|
||||||
|
"target_document_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"position": "first-child"
|
||||||
|
}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `target_document_id` is mandatory.
|
||||||
|
- The `position` defaults to "last-child" if not provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target_document_id = serializers.UUIDField(required=True)
|
||||||
|
position = serializers.ChoiceField(
|
||||||
|
choices=enums.MoveNodePositionChoices.choices,
|
||||||
|
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,66 @@
|
|||||||
"""Util to generate S3 authorization headers for object storage access control"""
|
"""Util to generate S3 authorization headers for object storage access control"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
import botocore
|
import botocore
|
||||||
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
|
||||||
|
def nest_tree(flat_list, steplen):
|
||||||
|
"""
|
||||||
|
Convert a flat list of serialized documents into a nested tree making advantage
|
||||||
|
of the`path` field and its step length.
|
||||||
|
"""
|
||||||
|
node_dict = {}
|
||||||
|
roots = []
|
||||||
|
|
||||||
|
# Sort the flat list by path to ensure parent nodes are processed first
|
||||||
|
flat_list.sort(key=lambda x: x["path"])
|
||||||
|
|
||||||
|
for node in flat_list:
|
||||||
|
node["children"] = [] # Initialize children list
|
||||||
|
node_dict[node["path"]] = node
|
||||||
|
|
||||||
|
# Determine parent path
|
||||||
|
parent_path = node["path"][:-steplen]
|
||||||
|
|
||||||
|
if parent_path in node_dict:
|
||||||
|
node_dict[parent_path]["children"].append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node) # Collect root nodes
|
||||||
|
|
||||||
|
if len(roots) > 1:
|
||||||
|
raise ValueError("More than one root element detected.")
|
||||||
|
|
||||||
|
return roots[0] if roots else None
|
||||||
|
|
||||||
|
|
||||||
|
def filter_root_paths(paths, skip_sorting=False):
|
||||||
|
"""
|
||||||
|
Filters root paths from a list of paths representing a tree structure.
|
||||||
|
A root path is defined as a path that is not a prefix of any other path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths (list of str): The list of paths.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of str: The filtered list of root paths.
|
||||||
|
"""
|
||||||
|
if not skip_sorting:
|
||||||
|
paths.sort()
|
||||||
|
|
||||||
|
root_paths = []
|
||||||
|
for path in paths:
|
||||||
|
# If the current path is not a prefix of the last added root path, add it
|
||||||
|
if not root_paths or not path.startswith(root_paths[-1]):
|
||||||
|
root_paths.append(path)
|
||||||
|
|
||||||
|
return root_paths
|
||||||
|
|
||||||
|
|
||||||
def generate_s3_authorization_headers(key):
|
def generate_s3_authorization_headers(key):
|
||||||
@@ -31,3 +89,93 @@ def generate_s3_authorization_headers(key):
|
|||||||
auth.add_auth(request)
|
auth.add_auth(request)
|
||||||
|
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
class AIBaseRateThrottle(BaseThrottle, ABC):
|
||||||
|
"""Base throttle class for AI-related rate limiting with backoff."""
|
||||||
|
|
||||||
|
def __init__(self, rates):
|
||||||
|
"""Initialize instance attributes with configurable rates."""
|
||||||
|
super().__init__()
|
||||||
|
self.rates = rates
|
||||||
|
self.cache_key = None
|
||||||
|
self.recent_requests_minute = 0
|
||||||
|
self.recent_requests_hour = 0
|
||||||
|
self.recent_requests_day = 0
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
"""Abstract method to generate cache key for throttling."""
|
||||||
|
|
||||||
|
def allow_request(self, request, view):
|
||||||
|
"""Check if the request is allowed based on rate limits."""
|
||||||
|
self.cache_key = self.get_cache_key(request, view)
|
||||||
|
if not self.cache_key:
|
||||||
|
return True # Allow if no cache key is generated
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
history = cache.get(self.cache_key, [])
|
||||||
|
# Keep requests within the last 24 hours
|
||||||
|
history = [req for req in history if req > now - 86400]
|
||||||
|
|
||||||
|
# Calculate recent requests
|
||||||
|
self.recent_requests_minute = len([req for req in history if req > now - 60])
|
||||||
|
self.recent_requests_hour = len([req for req in history if req > now - 3600])
|
||||||
|
self.recent_requests_day = len(history)
|
||||||
|
|
||||||
|
# Check rate limits
|
||||||
|
if self.recent_requests_minute >= self.rates["minute"]:
|
||||||
|
return False
|
||||||
|
if self.recent_requests_hour >= self.rates["hour"]:
|
||||||
|
return False
|
||||||
|
if self.recent_requests_day >= self.rates["day"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Log the request
|
||||||
|
history.append(now)
|
||||||
|
cache.set(self.cache_key, history, timeout=86400)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
"""Implement a backoff strategy by increasing wait time based on limits hit."""
|
||||||
|
if self.recent_requests_day >= self.rates["day"]:
|
||||||
|
return 86400
|
||||||
|
if self.recent_requests_hour >= self.rates["hour"]:
|
||||||
|
return 3600
|
||||||
|
if self.recent_requests_minute >= self.rates["minute"]:
|
||||||
|
return 60
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AIDocumentRateThrottle(AIBaseRateThrottle):
|
||||||
|
"""Throttle for limiting AI requests per document with backoff."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES)
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
"""Include document ID in the cache key."""
|
||||||
|
document_id = view.kwargs["pk"]
|
||||||
|
return f"document_{document_id}_throttle_ai"
|
||||||
|
|
||||||
|
|
||||||
|
class AIUserRateThrottle(AIBaseRateThrottle):
|
||||||
|
"""Throttle that limits requests per user or IP with backoff and rate limits."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(settings.AI_USER_RATE_THROTTLE_RATES)
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view=None):
|
||||||
|
"""Generate a cache key based on the user ID or IP for anonymous users."""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return f"user_{request.user.id!s}_throttle_ai"
|
||||||
|
return f"anonymous_{self.get_ident(request)}_throttle_ai"
|
||||||
|
|
||||||
|
def get_ident(self, request):
|
||||||
|
"""Return the request IP address."""
|
||||||
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
return (
|
||||||
|
x_forwarded_for.split(",")[0]
|
||||||
|
if x_forwarded_for
|
||||||
|
else request.META.get("REMOTE_ADDR")
|
||||||
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
"""Custom authentication classes for the Impress core app"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatedServer:
|
||||||
|
"""
|
||||||
|
Simple class to represent an authenticated server to be used along the
|
||||||
|
IsAuthenticated permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_authenticated = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServerToServerAuthentication(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
Custom authentication class for server-to-server requests.
|
||||||
|
Validates the presence and correctness of the Authorization header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AUTH_HEADER = "Authorization"
|
||||||
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""
|
||||||
|
Authenticate the server-to-server request by validating the Authorization header.
|
||||||
|
|
||||||
|
This method checks if the Authorization header is present in the request, ensures it
|
||||||
|
contains a valid token with the correct format, and verifies the token against the
|
||||||
|
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
|
||||||
|
or contains an invalid token, an AuthenticationFailed exception is raised.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None: If authentication is successful
|
||||||
|
(no user is authenticated for server-to-server requests).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If the Authorization header is missing, malformed,
|
||||||
|
or contains an invalid token.
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
if not auth_header:
|
||||||
|
raise AuthenticationFailed("Authorization header is missing.")
|
||||||
|
|
||||||
|
# Validate token format and existence
|
||||||
|
auth_parts = auth_header.split(" ")
|
||||||
|
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||||
|
# Do not raise here to leave the door open for other authentication methods
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = auth_parts[1]
|
||||||
|
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
||||||
|
# Do not raise here to leave the door open for other authentication methods
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Authentication is successful
|
||||||
|
return AuthenticatedServer(), token
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""Return the WWW-Authenticate header value."""
|
||||||
|
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
|
||||||
|
|||||||
@@ -1,100 +1,59 @@
|
|||||||
"""Authentication Backends for the Impress core app."""
|
"""Authentication Backends for the Impress core app."""
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
import logging
|
||||||
from django.utils.translation import gettext_lazy as _
|
import os
|
||||||
|
|
||||||
import requests
|
from django.conf import settings
|
||||||
from mozilla_django_oidc.auth import (
|
from django.core.exceptions import SuspiciousOperation
|
||||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
|
||||||
|
from lasuite.oidc_login.backends import (
|
||||||
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.models import 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."
|
||||||
|
)
|
||||||
|
|
||||||
|
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(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
|
|
||||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
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.
|
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_userinfo(self, access_token, id_token, payload):
|
def get_extra_claims(self, user_info):
|
||||||
"""Return user details dictionary.
|
"""
|
||||||
|
Return extra claims from user_info.
|
||||||
|
|
||||||
Parameters:
|
Args:
|
||||||
- access_token (str): The access token.
|
user_info (dict): The user information dictionary.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
dict: A dictionary of extra claims.
|
||||||
"""
|
"""
|
||||||
|
return {
|
||||||
|
"full_name": self.compute_full_name(user_info),
|
||||||
|
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||||
|
}
|
||||||
|
|
||||||
user_response = requests.get(
|
def get_existing_user(self, sub, email):
|
||||||
self.OIDC_OP_USER_ENDPOINT,
|
"""Fetch existing user by sub or email."""
|
||||||
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()
|
|
||||||
userinfo = self.verify_token(user_response.text)
|
|
||||||
return userinfo
|
|
||||||
|
|
||||||
def get_or_create_user(self, access_token, id_token, payload):
|
|
||||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- access_token (str): The access token.
|
|
||||||
- id_token (str): The ID token.
|
|
||||||
- payload (dict): The user payload.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- User: An existing or newly created User instance.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
|
||||||
sub = user_info.get("sub")
|
|
||||||
|
|
||||||
if sub is None:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
_("User info contained no recognizable user identification")
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(sub=sub)
|
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||||
except User.DoesNotExist:
|
except DuplicateEmailError as err:
|
||||||
if self.get_settings("OIDC_CREATE_USER", True):
|
raise SuspiciousOperation(err.message) from err
|
||||||
user = self.create_user(user_info)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
def create_user(self, claims):
|
|
||||||
"""Return a newly created User instance."""
|
|
||||||
|
|
||||||
sub = claims.get("sub")
|
|
||||||
|
|
||||||
if sub is None:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
_("Claims contained no recognizable user identification")
|
|
||||||
)
|
|
||||||
|
|
||||||
user = User.objects.create(
|
|
||||||
sub=sub,
|
|
||||||
email=claims.get("email"),
|
|
||||||
password="!", # noqa: S106
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -2,15 +2,47 @@
|
|||||||
Core application enums declaration
|
Core application enums declaration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
from django.conf import global_settings, settings
|
from django.conf import global_settings, settings
|
||||||
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
ATTACHMENTS_FOLDER = "attachments"
|
||||||
# the choice of languages which should not be limited to the few languages active in
|
UUID_REGEX = (
|
||||||
# the app.
|
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||||
# pylint: disable=no-member
|
|
||||||
ALL_LANGUAGES = getattr(
|
|
||||||
settings,
|
|
||||||
"ALL_LANGUAGES",
|
|
||||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
|
||||||
)
|
)
|
||||||
|
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||||
|
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||||
|
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
|
||||||
|
f"(?P<attachment>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
|
||||||
|
)
|
||||||
|
MEDIA_STORAGE_URL_EXTRACT = re.compile(
|
||||||
|
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
||||||
|
# We can use it for the choice of languages which should not be limited to the few languages
|
||||||
|
# active in the app.
|
||||||
|
# pylint: disable=no-member
|
||||||
|
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
|
||||||
|
|
||||||
|
|
||||||
|
class MoveNodePositionChoices(models.TextChoices):
|
||||||
|
"""Defines the possible positions when moving a django-treebeard node."""
|
||||||
|
|
||||||
|
FIRST_CHILD = "first-child", _("First child")
|
||||||
|
LAST_CHILD = "last-child", _("Last child")
|
||||||
|
FIRST_SIBLING = "first-sibling", _("First sibling")
|
||||||
|
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"
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ from core import models
|
|||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
||||||
|
YDOC_HELLO_WORLD_BASE64 = (
|
||||||
|
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
|
||||||
|
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
|
||||||
|
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
|
||||||
|
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
|
||||||
|
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
|
||||||
|
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
|
||||||
|
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
|
||||||
|
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
|
||||||
|
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
|
||||||
|
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
|
||||||
|
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
|
||||||
|
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
|
||||||
|
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserFactory(factory.django.DjangoModelFactory):
|
class UserFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to random users for testing purposes."""
|
"""A factory to random users for testing purposes."""
|
||||||
@@ -22,9 +38,46 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
|
full_name = factory.Faker("name")
|
||||||
|
short_name = factory.Faker("first_name")
|
||||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||||
password = make_password("password")
|
password = make_password("password")
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def with_owned_document(self, create, extracted, **kwargs):
|
||||||
|
"""
|
||||||
|
Create a document for which the user is owner to check
|
||||||
|
that there is no interference
|
||||||
|
"""
|
||||||
|
if create and (extracted is True):
|
||||||
|
UserDocumentAccessFactory(user=self, role="owner")
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def with_owned_template(self, create, extracted, **kwargs):
|
||||||
|
"""
|
||||||
|
Create a template for which the user is owner to check
|
||||||
|
that there is no interference
|
||||||
|
"""
|
||||||
|
if create and (extracted is True):
|
||||||
|
UserTemplateAccessFactory(user=self, role="owner")
|
||||||
|
|
||||||
|
|
||||||
|
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
|
||||||
|
"""Custom factory attribute for setting the parent node."""
|
||||||
|
|
||||||
|
def generate(self, step, params):
|
||||||
|
"""
|
||||||
|
Generate a parent node for the factory.
|
||||||
|
|
||||||
|
This method is invoked during the factory's build process to determine the parent
|
||||||
|
node of the current object being created. If `params` is provided, it uses the factory's
|
||||||
|
metadata to recursively create or fetch the parent node. Otherwise, it returns `None`.
|
||||||
|
"""
|
||||||
|
if not params:
|
||||||
|
return None
|
||||||
|
subfactory = step.builder.factory_meta.factory
|
||||||
|
return step.recurse(subfactory, params)
|
||||||
|
|
||||||
|
|
||||||
class DocumentFactory(factory.django.DjangoModelFactory):
|
class DocumentFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to create documents"""
|
"""A factory to create documents"""
|
||||||
@@ -34,8 +87,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
django_get_or_create = ("title",)
|
django_get_or_create = ("title",)
|
||||||
skip_postgeneration_save = True
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
|
parent = ParentNodeFactory()
|
||||||
|
|
||||||
title = factory.Sequence(lambda n: f"document{n}")
|
title = factory.Sequence(lambda n: f"document{n}")
|
||||||
content = factory.Sequence(lambda n: f"content{n}")
|
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
|
||||||
|
content = YDOC_HELLO_WORLD_BASE64
|
||||||
|
creator = factory.SubFactory(UserFactory)
|
||||||
|
deleted_at = None
|
||||||
link_reach = factory.fuzzy.FuzzyChoice(
|
link_reach = factory.fuzzy.FuzzyChoice(
|
||||||
[a[0] for a in models.LinkReachChoices.choices]
|
[a[0] for a in models.LinkReachChoices.choices]
|
||||||
)
|
)
|
||||||
@@ -43,6 +101,29 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
[r[0] for r in models.LinkRoleChoices.choices]
|
[r[0] for r in models.LinkRoleChoices.choices]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, model_class, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom creation logic for the factory: creates a document as a child node if
|
||||||
|
a parent is provided; otherwise, creates it as a root node.
|
||||||
|
"""
|
||||||
|
parent = kwargs.pop("parent", None)
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
# Add as a child node
|
||||||
|
kwargs["ancestors_deleted_at"] = (
|
||||||
|
kwargs.get("ancestors_deleted_at") or parent.ancestors_deleted_at
|
||||||
|
)
|
||||||
|
return parent.add_child(instance=model_class(**kwargs))
|
||||||
|
|
||||||
|
# Add as a root node
|
||||||
|
return model_class.add_root(instance=model_class(**kwargs))
|
||||||
|
|
||||||
|
@factory.lazy_attribute
|
||||||
|
def ancestors_deleted_at(self):
|
||||||
|
"""Should always be set when "deleted_at" is set."""
|
||||||
|
return self.deleted_at
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def users(self, create, extracted, **kwargs):
|
def users(self, create, extracted, **kwargs):
|
||||||
"""Add users to document from a given list of users with or without roles."""
|
"""Add users to document from a given list of users with or without roles."""
|
||||||
@@ -53,6 +134,16 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
else:
|
else:
|
||||||
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
|
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def teams(self, create, extracted, **kwargs):
|
||||||
|
"""Add teams to document from a given list of teams with or without roles."""
|
||||||
|
if create and extracted:
|
||||||
|
for item in extracted:
|
||||||
|
if isinstance(item, str):
|
||||||
|
TeamDocumentAccessFactory(document=self, team=item)
|
||||||
|
else:
|
||||||
|
TeamDocumentAccessFactory(document=self, team=item[0], role=item[1])
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def link_traces(self, create, extracted, **kwargs):
|
def link_traces(self, create, extracted, **kwargs):
|
||||||
"""Add link traces to document from a given list of users."""
|
"""Add link traces to document from a given list of users."""
|
||||||
@@ -60,6 +151,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.LinkTrace.objects.create(document=self, user=item)
|
models.LinkTrace.objects.create(document=self, user=item)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def favorited_by(self, create, extracted, **kwargs):
|
||||||
|
"""Mark document as favorited by a list of users."""
|
||||||
|
if create and extracted:
|
||||||
|
for item in extracted:
|
||||||
|
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||||
|
|
||||||
|
|
||||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake document user accesses for testing."""
|
"""Create fake document user accesses for testing."""
|
||||||
|
|||||||
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)
|
||||||
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Management command updating the metadata for all the files in the MinIO bucket."""
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
import magic
|
||||||
|
|
||||||
|
from core.models import Document
|
||||||
|
|
||||||
|
# pylint: disable=too-many-locals, broad-exception-caught
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Update the metadata for all the files in the MinIO bucket."""
|
||||||
|
|
||||||
|
help = __doc__
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Execute management command."""
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
bucket_name = default_storage.bucket_name
|
||||||
|
|
||||||
|
mime_detector = magic.Magic(mime=True)
|
||||||
|
|
||||||
|
documents = Document.objects.all()
|
||||||
|
self.stdout.write(
|
||||||
|
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
doc_id_str = str(doc.id)
|
||||||
|
prefix = f"{doc_id_str}/attachments/"
|
||||||
|
self.stdout.write(
|
||||||
|
f"[INFO] Processing attachments under prefix '{prefix}' ..."
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation_token = None
|
||||||
|
total_updated = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
|
||||||
|
if continuation_token:
|
||||||
|
list_kwargs["ContinuationToken"] = continuation_token
|
||||||
|
|
||||||
|
response = s3_client.list_objects_v2(**list_kwargs)
|
||||||
|
|
||||||
|
# If no objects found under this prefix, break out of the loop
|
||||||
|
if "Contents" not in response:
|
||||||
|
break
|
||||||
|
|
||||||
|
for obj in response["Contents"]:
|
||||||
|
key = obj["Key"]
|
||||||
|
|
||||||
|
# Skip if it's a folder
|
||||||
|
if key.endswith("/"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get existing metadata
|
||||||
|
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||||
|
|
||||||
|
# Read first ~1KB for MIME detection
|
||||||
|
partial_obj = s3_client.get_object(
|
||||||
|
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
|
||||||
|
)
|
||||||
|
partial_data = partial_obj["Body"].read()
|
||||||
|
|
||||||
|
# Detect MIME type
|
||||||
|
magic_mime_type = mime_detector.from_buffer(partial_data)
|
||||||
|
|
||||||
|
# Update ContentType
|
||||||
|
s3_client.copy_object(
|
||||||
|
Bucket=bucket_name,
|
||||||
|
CopySource={"Bucket": bucket_name, "Key": key},
|
||||||
|
Key=key,
|
||||||
|
ContentType=magic_mime_type,
|
||||||
|
Metadata=head_resp.get("Metadata", {}),
|
||||||
|
MetadataDirective="REPLACE",
|
||||||
|
)
|
||||||
|
total_updated += 1
|
||||||
|
|
||||||
|
except Exception as exc: # noqa
|
||||||
|
self.stderr.write(
|
||||||
|
f"[ERROR] Could not update ContentType for {key}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.get("IsTruncated"):
|
||||||
|
continuation_token = response.get("NextContinuationToken")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if total_updated > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
|
||||||
|
)
|
||||||
@@ -1,166 +1,552 @@
|
|||||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import timezone_field.fields
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import timezone_field.fields
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Document',
|
name="Document",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
default=uuid.uuid4,
|
||||||
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||||
|
(
|
||||||
|
"is_public",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this document is public for anyone to use.",
|
||||||
|
verbose_name="public",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Document',
|
"verbose_name": "Document",
|
||||||
'verbose_name_plural': 'Documents',
|
"verbose_name_plural": "Documents",
|
||||||
'db_table': 'impress_document',
|
"db_table": "impress_document",
|
||||||
'ordering': ('title',),
|
"ordering": ("title",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Template',
|
name="Template",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
default=uuid.uuid4,
|
||||||
('description', models.TextField(blank=True, verbose_name='description')),
|
editable=False,
|
||||||
('code', models.TextField(blank=True, verbose_name='code')),
|
help_text="primary key for the record as UUID",
|
||||||
('css', models.TextField(blank=True, verbose_name='css')),
|
primary_key=True,
|
||||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(blank=True, verbose_name="description"),
|
||||||
|
),
|
||||||
|
("code", models.TextField(blank=True, verbose_name="code")),
|
||||||
|
("css", models.TextField(blank=True, verbose_name="css")),
|
||||||
|
(
|
||||||
|
"is_public",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this template is public for anyone to use.",
|
||||||
|
verbose_name="public",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Template',
|
"verbose_name": "Template",
|
||||||
'verbose_name_plural': 'Templates',
|
"verbose_name_plural": "Templates",
|
||||||
'db_table': 'impress_template',
|
"db_table": "impress_template",
|
||||||
'ordering': ('title',),
|
"ordering": ("title",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='User',
|
name="User",
|
||||||
fields=[
|
fields=[
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
(
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
"last_login",
|
||||||
('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')),
|
models.DateTimeField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
blank=True, null=True, verbose_name="last login"
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
),
|
||||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
),
|
||||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
(
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
"is_superuser",
|
||||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
models.BooleanField(
|
||||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
default=False,
|
||||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
verbose_name="superuser status",
|
||||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
),
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
),
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
(
|
||||||
|
"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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sub",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
|
||||||
|
regex="^[\\w.@+-]+\\Z",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
verbose_name="sub",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
max_length=254,
|
||||||
|
null=True,
|
||||||
|
verbose_name="identity email address",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"admin_email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
max_length=254,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="admin email address",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timezone",
|
||||||
|
timezone_field.fields.TimeZoneField(
|
||||||
|
choices_display="WITH_GMT_OFFSET",
|
||||||
|
default="UTC",
|
||||||
|
help_text="The timezone in which the user wants to see times.",
|
||||||
|
use_pytz=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_device",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether the user is a device or a real user.",
|
||||||
|
verbose_name="device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'user',
|
"verbose_name": "user",
|
||||||
'verbose_name_plural': 'users',
|
"verbose_name_plural": "users",
|
||||||
'db_table': 'impress_user',
|
"db_table": "impress_user",
|
||||||
},
|
},
|
||||||
managers=[
|
managers=[
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DocumentAccess',
|
name="DocumentAccess",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('team', models.CharField(blank=True, max_length=100)),
|
default=uuid.uuid4,
|
||||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
editable=False,
|
||||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
|
help_text="primary key for the record as UUID",
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("team", models.CharField(blank=True, max_length=100)),
|
||||||
|
(
|
||||||
|
"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="accesses",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Document/user relation',
|
"verbose_name": "Document/user relation",
|
||||||
'verbose_name_plural': 'Document/user relations',
|
"verbose_name_plural": "Document/user relations",
|
||||||
'db_table': 'impress_document_access',
|
"db_table": "impress_document_access",
|
||||||
'ordering': ('-created_at',),
|
"ordering": ("-created_at",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Invitation',
|
name="Invitation",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
default=uuid.uuid4,
|
||||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
editable=False,
|
||||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
|
help_text="primary key for the record as UUID",
|
||||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(max_length=254, verbose_name="email address"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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="invitations",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"issuer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invitations",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Document invitation',
|
"verbose_name": "Document invitation",
|
||||||
'verbose_name_plural': 'Document invitations',
|
"verbose_name_plural": "Document invitations",
|
||||||
'db_table': 'impress_invitation',
|
"db_table": "impress_invitation",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TemplateAccess',
|
name="TemplateAccess",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('team', models.CharField(blank=True, max_length=100)),
|
default=uuid.uuid4,
|
||||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
editable=False,
|
||||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
|
help_text="primary key for the record as UUID",
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("team", models.CharField(blank=True, max_length=100)),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"template",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="accesses",
|
||||||
|
to="core.template",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Template/user relation',
|
"verbose_name": "Template/user relation",
|
||||||
'verbose_name_plural': 'Template/user relations',
|
"verbose_name_plural": "Template/user relations",
|
||||||
'db_table': 'impress_template_access',
|
"db_table": "impress_template_access",
|
||||||
'ordering': ('-created_at',),
|
"ordering": ("-created_at",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='documentaccess',
|
model_name="documentaccess",
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("user__isnull", False)),
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_document_user",
|
||||||
|
violation_error_message="This user is already in this document.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='documentaccess',
|
model_name="documentaccess",
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("team__gt", "")),
|
||||||
|
fields=("team", "document"),
|
||||||
|
name="unique_document_team",
|
||||||
|
violation_error_message="This team is already in this document.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='documentaccess',
|
model_name="documentaccess",
|
||||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(
|
||||||
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
|
_connector="OR",
|
||||||
|
),
|
||||||
|
name="check_document_access_either_user_or_team",
|
||||||
|
violation_error_message="Either user or team must be set, not both.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='invitation',
|
model_name="invitation",
|
||||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("email", "document"), name="email_and_document_unique_together"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='templateaccess',
|
model_name="templateaccess",
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("user__isnull", False)),
|
||||||
|
fields=("user", "template"),
|
||||||
|
name="unique_template_user",
|
||||||
|
violation_error_message="This user is already in this template.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='templateaccess',
|
model_name="templateaccess",
|
||||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("team__gt", "")),
|
||||||
|
fields=("team", "template"),
|
||||||
|
name="unique_template_team",
|
||||||
|
violation_error_message="This team is already in this template.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='templateaccess',
|
model_name="templateaccess",
|
||||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(
|
||||||
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
|
_connector="OR",
|
||||||
|
),
|
||||||
|
name="check_template_access_either_user_or_team",
|
||||||
|
violation_error_message="Either user or team must be set, not both.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0001_initial'),
|
("core", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -1,52 +1,114 @@
|
|||||||
# Generated by Django 5.1 on 2024-09-08 16:55
|
# Generated by Django 5.1 on 2024-09-08 16:55
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0002_create_pg_trgm_extension'),
|
("core", "0002_create_pg_trgm_extension"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='document',
|
model_name="document",
|
||||||
name='link_reach',
|
name="link_reach",
|
||||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("restricted", "Restricted"),
|
||||||
|
("authenticated", "Authenticated"),
|
||||||
|
("public", "Public"),
|
||||||
|
],
|
||||||
|
default="authenticated",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='document',
|
model_name="document",
|
||||||
name='link_role',
|
name="link_role",
|
||||||
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[("reader", "Reader"), ("editor", "Editor")],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='document',
|
model_name="document",
|
||||||
name='is_public',
|
name="is_public",
|
||||||
field=models.BooleanField(null=True),
|
field=models.BooleanField(null=True),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name="user",
|
||||||
name='language',
|
name="language",
|
||||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='LinkTrace',
|
name="LinkTrace",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
models.UUIDField(
|
||||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
|
default=uuid.uuid4,
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="link_traces",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="link_traces",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Document/user link trace',
|
"verbose_name": "Document/user link trace",
|
||||||
'verbose_name_plural': 'Document/user link traces',
|
"verbose_name_plural": "Document/user link traces",
|
||||||
'db_table': 'impress_link_trace',
|
"db_table": "impress_link_trace",
|
||||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_link_trace_document_user",
|
||||||
|
violation_error_message="A link trace already exists for this document/user.",
|
||||||
|
)
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# Generated by Django 5.1 on 2024-09-08 17:04
|
# Generated by Django 5.1 on 2024-09-08 17:04
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
def migrate_is_public_to_link_reach(apps, schema_editor):
|
def migrate_is_public_to_link_reach(apps, schema_editor):
|
||||||
"""
|
"""
|
||||||
Forward migration: Migrate 'is_public' to 'link_reach'.
|
Forward migration: Migrate 'is_public' to 'link_reach'.
|
||||||
If is_public == True, set link_reach to 'public'
|
If is_public == True, set link_reach to 'public'
|
||||||
"""
|
"""
|
||||||
Document = apps.get_model('core', 'Document')
|
Document = apps.get_model("core", "Document")
|
||||||
Document.objects.filter(is_public=True).update(link_reach='public')
|
Document.objects.filter(is_public=True).update(link_reach="public")
|
||||||
|
|
||||||
|
|
||||||
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||||
@@ -16,20 +17,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
|||||||
- If link_reach == 'public', set is_public to True
|
- If link_reach == 'public', set is_public to True
|
||||||
- Else set is_public to False
|
- Else set is_public to False
|
||||||
"""
|
"""
|
||||||
Document = apps.get_model('core', 'Document')
|
Document = apps.get_model("core", "Document")
|
||||||
Document.objects.filter(link_reach='public').update(is_public=True)
|
Document.objects.filter(link_reach="public").update(is_public=True)
|
||||||
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
|
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
|
||||||
|
is_public=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0003_document_link_reach_document_link_role_and_more'),
|
("core", "0003_document_link_reach_document_link_role_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(
|
migrations.RunPython(
|
||||||
migrate_is_public_to_link_reach,
|
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
|
||||||
reverse_migrate_link_reach_to_is_public
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0004_migrate_is_public_to_link_reach'),
|
("core", "0004_migrate_is_public_to_link_reach"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='document',
|
model_name="document",
|
||||||
name='title',
|
name="title",
|
||||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
|
field=models.CharField(
|
||||||
|
blank=True, max_length=255, null=True, verbose_name="title"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-09-29 03:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="full_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, max_length=100, null=True, verbose_name="full name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="short_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, max_length=20, null=True, verbose_name="short name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-10-10 11:45
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
procedure = """
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
user_email TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Step 1: Create a temporary table (without the unique constraint)
|
||||||
|
-- impress_document_access
|
||||||
|
DROP TABLE IF EXISTS impress_document_access_tmp;
|
||||||
|
CREATE TEMP TABLE impress_document_access_tmp AS
|
||||||
|
SELECT * FROM impress_document_access;
|
||||||
|
|
||||||
|
-- impress_link_trace
|
||||||
|
DROP TABLE IF EXISTS impress_link_trace_tmp;
|
||||||
|
CREATE TEMP TABLE impress_link_trace_tmp AS
|
||||||
|
SELECT * FROM impress_link_trace;
|
||||||
|
|
||||||
|
-- Step 2: Loop through each email that appears more than once
|
||||||
|
FOR user_email IN
|
||||||
|
SELECT email
|
||||||
|
FROM impress_user
|
||||||
|
GROUP BY email
|
||||||
|
HAVING COUNT(email) > 1
|
||||||
|
LOOP
|
||||||
|
-- Step 3: Update user_id in the temporary table based on email
|
||||||
|
-- For impress_document_access
|
||||||
|
UPDATE impress_document_access_tmp
|
||||||
|
SET user_id = (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE user_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
);
|
||||||
|
|
||||||
|
-- For impress_link_trace
|
||||||
|
UPDATE impress_link_trace_tmp
|
||||||
|
SET user_id = (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE user_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
);
|
||||||
|
|
||||||
|
-- update impress_invitation
|
||||||
|
UPDATE impress_invitation
|
||||||
|
SET issuer_id = (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE issuer_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM impress_user
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
)
|
||||||
|
AND id != (
|
||||||
|
SELECT id
|
||||||
|
FROM impress_user
|
||||||
|
WHERE email = user_email
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Processed updates for email: %', user_email;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
|
||||||
|
-- For impress_document_access
|
||||||
|
DELETE FROM impress_document_access_tmp a
|
||||||
|
USING impress_document_access_tmp b
|
||||||
|
WHERE a.ctid < b.ctid -- Keep one row
|
||||||
|
AND a.document_id = b.document_id
|
||||||
|
AND a.user_id = b.user_id;
|
||||||
|
|
||||||
|
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||||
|
TRUNCATE TABLE impress_document_access;
|
||||||
|
|
||||||
|
-- Insert cleaned-up data back into the original table
|
||||||
|
INSERT INTO impress_document_access
|
||||||
|
SELECT * FROM impress_document_access_tmp;
|
||||||
|
|
||||||
|
-- For impress_link_trace
|
||||||
|
DELETE FROM impress_link_trace_tmp a
|
||||||
|
USING impress_link_trace_tmp b
|
||||||
|
WHERE a.ctid < b.ctid -- Keep one row
|
||||||
|
AND a.document_id = b.document_id
|
||||||
|
AND a.user_id = b.user_id;
|
||||||
|
|
||||||
|
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||||
|
TRUNCATE TABLE impress_link_trace;
|
||||||
|
|
||||||
|
-- Insert cleaned-up data back into the original table
|
||||||
|
INSERT INTO impress_link_trace
|
||||||
|
SELECT * FROM impress_link_trace_tmp;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Update and deduplication process completed.';
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0006_add_user_full_name_and_short_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(procedure),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-10-25 11:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0007_fix_users_duplicate"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="document",
|
||||||
|
name="link_reach",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("restricted", "Restricted"),
|
||||||
|
("authenticated", "Authenticated"),
|
||||||
|
("public", "Public"),
|
||||||
|
],
|
||||||
|
default="restricted",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
87
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
87
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-08 07: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", "0008_alter_document_link_reach"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentFavorite",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="favorited_by_users",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="favorite_documents",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Document favorite",
|
||||||
|
"verbose_name_plural": "Document favorites",
|
||||||
|
"db_table": "impress_document_favorite",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_document_favorite_user",
|
||||||
|
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-09 11:36
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0009_add_document_favorite"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="creator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.RESTRICT,
|
||||||
|
related_name="documents_created",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="sub",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
|
||||||
|
regex="^[\\w.@+-:]+\\Z",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
verbose_name="sub",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-09 11:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
|
||||||
|
|
||||||
|
|
||||||
|
def set_creator_from_document_access(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Populate the `creator` field for existing Document records.
|
||||||
|
|
||||||
|
This function assigns the `creator` field using the existing
|
||||||
|
DocumentAccess entries. We can be sure that all documents have at
|
||||||
|
least one user with "owner" role. If the document has several roles,
|
||||||
|
it should take the entry with the oldest date of creation.
|
||||||
|
|
||||||
|
The update is performed using efficient bulk queries with Django's
|
||||||
|
Subquery and OuterRef to minimize database hits and ensure performance.
|
||||||
|
|
||||||
|
Note: After running this migration, we quickly modify the schema to make
|
||||||
|
the `creator` field required.
|
||||||
|
"""
|
||||||
|
Document = apps.get_model("core", "Document")
|
||||||
|
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||||
|
|
||||||
|
# Update `creator` using the "owner" role
|
||||||
|
owner_subquery = (
|
||||||
|
DocumentAccess.objects.filter(
|
||||||
|
document=OuterRef("pk"),
|
||||||
|
user__isnull=False,
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
|
.order_by("created_at")
|
||||||
|
.values("user_id")[:1]
|
||||||
|
)
|
||||||
|
|
||||||
|
Document.objects.filter(creator__isnull=True).update(
|
||||||
|
creator=Subquery(owner_subquery)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0010_add_field_creator_to_document"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="document",
|
||||||
|
name="creator",
|
||||||
|
field=ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.RESTRICT,
|
||||||
|
related_name="documents_created",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-30 22:23
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0011_populate_creator_field_and_make_it_required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="document",
|
||||||
|
name="creator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.RESTRICT,
|
||||||
|
related_name="documents_created",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="invitation",
|
||||||
|
name="issuer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invitations",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-25 08:38
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
"CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;",
|
||||||
|
reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-12-07 09:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0013_activate_fuzzystrmatch_extension"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="depth",
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="numchild",
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="path",
|
||||||
|
# Allow null values pending the next datamigration to populate the field
|
||||||
|
field=models.CharField(
|
||||||
|
db_collation="C", max_length=252, null=True, unique=True
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-12-07 10:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from treebeard.numconv import NumConv
|
||||||
|
|
||||||
|
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
STEPLEN = 7
|
||||||
|
|
||||||
|
|
||||||
|
def set_path_on_existing_documents(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Updates the `path` and `depth` fields for all existing Document records
|
||||||
|
to ensure valid materialized paths.
|
||||||
|
|
||||||
|
This function assigns a unique `path` to each Document as a root node
|
||||||
|
|
||||||
|
Note: After running this migration, we quickly modify the schema to make
|
||||||
|
the `path` field required as it should.
|
||||||
|
"""
|
||||||
|
Document = apps.get_model("core", "Document")
|
||||||
|
|
||||||
|
# Iterate over all existing documents and make them root nodes
|
||||||
|
documents = Document.objects.order_by("created_at").values_list("id", flat=True)
|
||||||
|
numconv = NumConv(len(ALPHABET), ALPHABET)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
for i, pk in enumerate(documents):
|
||||||
|
key = numconv.int2str(i)
|
||||||
|
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
|
||||||
|
updates.append(Document(pk=pk, path=path, depth=1))
|
||||||
|
|
||||||
|
# Bulk update using the prepared updates list
|
||||||
|
Document.objects.bulk_update(updates, ["depth", "path"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0014_add_tree_structure_to_documents"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="document",
|
||||||
|
name="path",
|
||||||
|
field=models.CharField(db_collation="C", max_length=252, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
src/backend/core/migrations/0016_add_document_excerpt.py
Normal file
30
src/backend/core/migrations/0016_add_document_excerpt.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-18 08:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0015_set_path_on_existing_documents"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="excerpt",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True, max_length=300, null=True, verbose_name="excerpt"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-12 14:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0016_add_document_excerpt"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="document",
|
||||||
|
options={
|
||||||
|
"ordering": ("path",),
|
||||||
|
"verbose_name": "Document",
|
||||||
|
"verbose_name_plural": "Documents",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="ancestors_deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||||
|
default="en-us",
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="document",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(
|
||||||
|
("deleted_at__isnull", True),
|
||||||
|
("deleted_at", models.F("ancestors_deleted_at")),
|
||||||
|
_connector="OR",
|
||||||
|
),
|
||||||
|
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def update_titles_to_null(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
|
||||||
|
we set them to Null
|
||||||
|
"""
|
||||||
|
Document = apps.get_model("core", "Document")
|
||||||
|
Document.objects.filter(
|
||||||
|
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
|
||||||
|
).update(title=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0017_add_fields_for_soft_delete"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
update_titles_to_null, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-03-04 12:23
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0018_update_blank_title"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name="user",
|
||||||
|
managers=[
|
||||||
|
("objects", core.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("en-us", "English"),
|
||||||
|
("fr-fr", "Français"),
|
||||||
|
("de-de", "Deutsch"),
|
||||||
|
],
|
||||||
|
default=None,
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-18 11:53
|
||||||
|
import re
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
from core.utils import extract_attachments
|
||||||
|
|
||||||
|
|
||||||
|
def populate_attachments_on_all_documents(apps, schema_editor):
|
||||||
|
"""Populate "attachments" field on all existing documents in the database."""
|
||||||
|
Document = apps.get_model("core", "Document")
|
||||||
|
|
||||||
|
for document in Document.objects.all():
|
||||||
|
try:
|
||||||
|
response = default_storage.connection.meta.client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=f"{document.pk!s}/file"
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ClientError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
content = response["Body"].read().decode("utf-8")
|
||||||
|
document.attachments = extract_attachments(content)
|
||||||
|
document.save(update_fields=["attachments"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0019_alter_user_language_default_to_null"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# v2.0.0 was released so we can now remove BC field "is_public"
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="document",
|
||||||
|
name="is_public",
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name="user",
|
||||||
|
managers=[
|
||||||
|
("objects", core.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="attachments",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(max_length=255),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="duplicated_from",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="duplicates",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
populate_attachments_on_all_documents,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()]
|
||||||
File diff suppressed because it is too large
Load Diff
0
src/backend/core/services/__init__.py
Normal file
0
src/backend/core/services/__init__.py
Normal file
93
src/backend/core/services/ai_services.py
Normal file
93
src/backend/core/services/ai_services.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""AI services."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from core import enums
|
||||||
|
|
||||||
|
AI_ACTIONS = {
|
||||||
|
"prompt": (
|
||||||
|
"Answer the prompt in markdown format. "
|
||||||
|
"Preserve the language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
"correct": (
|
||||||
|
"Correct grammar and spelling of the markdown text, "
|
||||||
|
"preserving language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
"rephrase": (
|
||||||
|
"Rephrase the given markdown text, "
|
||||||
|
"preserving language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
"summarize": (
|
||||||
|
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
"beautify": (
|
||||||
|
"Add formatting to the text to make it more readable. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
"emojify": (
|
||||||
|
"Add emojis to the important parts of the text. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
AI_TRANSLATE = (
|
||||||
|
"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."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIService:
|
||||||
|
"""Service class for AI-related operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Ensure that the AI configuration is set properly."""
|
||||||
|
if (
|
||||||
|
settings.AI_BASE_URL is None
|
||||||
|
or settings.AI_API_KEY is None
|
||||||
|
or settings.AI_MODEL is None
|
||||||
|
):
|
||||||
|
raise ImproperlyConfigured("AI configuration not set")
|
||||||
|
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
|
||||||
|
|
||||||
|
def call_ai_api(self, system_content, text):
|
||||||
|
"""Helper method to call the OpenAI API and process the response."""
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=settings.AI_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_content},
|
||||||
|
{"role": "user", "content": text},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError("AI response does not contain an answer")
|
||||||
|
|
||||||
|
return {"answer": content}
|
||||||
|
|
||||||
|
def transform(self, text, action):
|
||||||
|
"""Transform text based on specified action."""
|
||||||
|
system_content = AI_ACTIONS[action]
|
||||||
|
return self.call_ai_api(system_content, text)
|
||||||
|
|
||||||
|
def translate(self, text, language):
|
||||||
|
"""Translate text to a specified language."""
|
||||||
|
language_display = enums.ALL_LANGUAGES.get(language, language)
|
||||||
|
system_content = AI_TRANSLATE.format(language=language_display)
|
||||||
|
return self.call_ai_api(system_content, text)
|
||||||
43
src/backend/core/services/collaboration_services.py
Normal file
43
src/backend/core/services/collaboration_services.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Collaboration services."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationService:
|
||||||
|
"""Service class for Collaboration related operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Ensure that the collaboration configuration is set properly."""
|
||||||
|
if settings.COLLABORATION_API_URL is None:
|
||||||
|
raise ImproperlyConfigured("Collaboration configuration not set")
|
||||||
|
|
||||||
|
def reset_connections(self, room, user_id=None):
|
||||||
|
"""
|
||||||
|
Reset connections of a room in the collaboration server.
|
||||||
|
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"
|
||||||
|
|
||||||
|
# room is necessary as a parameter, it is easier to stick to the
|
||||||
|
# same pod thanks to a parameter
|
||||||
|
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
|
||||||
|
|
||||||
|
# Note: Collaboration microservice accepts only raw token, which is not recommended
|
||||||
|
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
|
||||||
|
if user_id:
|
||||||
|
headers["X-User-Id"] = user_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(endpoint_url, headers=headers, timeout=10)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise requests.HTTPError("Failed to notify WebSocket server.") from e
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||||
|
f"Response: {response.text}"
|
||||||
|
)
|
||||||
78
src/backend/core/services/converter_services.py
Normal file
78
src/backend/core/services/converter_services.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Converter services."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionError(Exception):
|
||||||
|
"""Base exception for conversion-related errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(ConversionError):
|
||||||
|
"""Raised when the input validation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUnavailableError(ConversionError):
|
||||||
|
"""Raised when the conversion service is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidResponseError(ConversionError):
|
||||||
|
"""Raised when the conversion service returns an invalid response."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingContentError(ConversionError):
|
||||||
|
"""Raised when the response is missing required content."""
|
||||||
|
|
||||||
|
|
||||||
|
class YdocConverter:
|
||||||
|
"""Service class for conversion-related operations."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_header(self):
|
||||||
|
"""Build microservice authentication header."""
|
||||||
|
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||||
|
return settings.Y_PROVIDER_API_KEY
|
||||||
|
|
||||||
|
def convert_markdown(self, text):
|
||||||
|
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
raise ValidationError("Input text cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||||
|
json={
|
||||||
|
"content": text,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": self.auth_header,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||||
|
verify=settings.CONVERSION_API_SECURE,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
conversion_response = response.json()
|
||||||
|
|
||||||
|
except requests.RequestException as err:
|
||||||
|
raise ServiceUnavailableError(
|
||||||
|
"Failed to connect to conversion service",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
except ValueError as err:
|
||||||
|
raise InvalidResponseError(
|
||||||
|
"Could not parse conversion service response"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_content = conversion_response[
|
||||||
|
settings.CONVERSION_API_CONTENT_FIELD
|
||||||
|
]
|
||||||
|
except KeyError as err:
|
||||||
|
raise MissingContentError(
|
||||||
|
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
return document_content
|
||||||
Binary file not shown.
@@ -1,8 +1,15 @@
|
|||||||
"""Unit tests for the Authentication Backends."""
|
"""Unit tests for the Authentication Backends."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
import pytest
|
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 import models
|
||||||
from core.authentication.backends import OIDCAuthenticationBackend
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
@@ -34,6 +41,233 @@ def test_authentication_getter_existing_user_no_email(
|
|||||||
assert user == db_user
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_user_via_email(
|
||||||
|
django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
If an existing user doesn't match the sub but matches the email,
|
||||||
|
the user should be returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory()
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": db_user.email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
with django_assert_num_queries(3): # user by sub, user by mail, update sub
|
||||||
|
user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_email_none(monkeypatch):
|
||||||
|
"""
|
||||||
|
If no user is found with the sub and no email is provided, a new user should be created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(email=None)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
user_info = {"sub": "123"}
|
||||||
|
if random.choice([True, False]):
|
||||||
|
user_info["email"] = None
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Since the sub and email didn't match, it should create a new user
|
||||||
|
assert models.User.objects.count() == 2
|
||||||
|
assert user != db_user
|
||||||
|
assert user.sub == "123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
||||||
|
settings, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||||
|
the system should not match users by email, even if the email matches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory()
|
||||||
|
|
||||||
|
# Set the setting to False
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||||
|
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": db_user.email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Since the sub doesn't match, it should create a new user
|
||||||
|
assert models.User.objects.count() == 2
|
||||||
|
assert user != db_user
|
||||||
|
assert user.sub == "123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||||
|
settings, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||||
|
the system should not match users by email, even if the email matches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory()
|
||||||
|
|
||||||
|
# Set the setting to False
|
||||||
|
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||||
|
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {"sub": "123", "email": db_user.email}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match=(
|
||||||
|
"We couldn't find a user with this sub but the email is already associated "
|
||||||
|
"with a registered user."
|
||||||
|
),
|
||||||
|
):
|
||||||
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
|
# Since the sub doesn't match, it should not create a new user
|
||||||
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_user_with_email(
|
||||||
|
django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the user's info contains an email and targets an existing user,
|
||||||
|
"""
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
user = UserFactory(full_name="John Doe", short_name="John")
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {
|
||||||
|
"sub": user.sub,
|
||||||
|
"email": user.email,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
# Only 1 query because email and names have not changed
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
authenticated_user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user == authenticated_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"first_name, last_name, email",
|
||||||
|
[
|
||||||
|
("Jack", "Doe", "john.doe@example.com"),
|
||||||
|
("John", "Duy", "john.doe@example.com"),
|
||||||
|
("John", "Doe", "jack.duy@example.com"),
|
||||||
|
("Jack", "Duy", "jack.duy@example.com"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_authentication_getter_existing_user_change_fields_sub(
|
||||||
|
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should update the email or name fields on the user when they change
|
||||||
|
and the user was identified by its "sub".
|
||||||
|
"""
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
user = UserFactory(
|
||||||
|
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {
|
||||||
|
"sub": user.sub,
|
||||||
|
"email": email,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
# One and only one additional update query when a field has changed
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
authenticated_user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user == authenticated_user
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.email == email
|
||||||
|
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||||
|
assert user.short_name == first_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"first_name, last_name, email",
|
||||||
|
[
|
||||||
|
("Jack", "Doe", "john.doe@example.com"),
|
||||||
|
("John", "Duy", "john.doe@example.com"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_authentication_getter_existing_user_change_fields_email(
|
||||||
|
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should update the name fields on the user when they change
|
||||||
|
and the user was identified by its "email" as fallback.
|
||||||
|
"""
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
user = UserFactory(
|
||||||
|
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {
|
||||||
|
"sub": "123",
|
||||||
|
"email": user.email,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
# One and only one additional update query when a field has changed
|
||||||
|
with django_assert_num_queries(3):
|
||||||
|
authenticated_user = klass.get_or_create_user(
|
||||||
|
access_token="test-token", id_token=None, payload=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user == authenticated_user
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.email == email
|
||||||
|
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||||
|
assert user.short_name == first_name
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches the user's info sub, a user should be created.
|
If no user matches the user's info sub, a user should be created.
|
||||||
@@ -52,7 +286,9 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
|
|
||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email is None
|
assert user.email is None
|
||||||
assert user.password == "!"
|
assert user.full_name is None
|
||||||
|
assert user.short_name is None
|
||||||
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -77,28 +313,199 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
|||||||
|
|
||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.password == "!"
|
assert user.full_name == "John Doe"
|
||||||
|
assert user.short_name == "John"
|
||||||
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||||
"""The user's info doesn't contain a sub."""
|
@responses.activate
|
||||||
|
def test_authentication_get_userinfo_json_response():
|
||||||
|
"""Test get_userinfo method with a JSON response."""
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
re.compile(r".*/userinfo"),
|
||||||
|
json={
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
oidc_backend = OIDCAuthenticationBackend()
|
||||||
|
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||||
|
|
||||||
|
assert result["first_name"] == "John"
|
||||||
|
assert result["last_name"] == "Doe"
|
||||||
|
assert result["email"] == "john.doe@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||||
|
@responses.activate
|
||||||
|
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,
|
||||||
|
content_type="application/jwt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||||
|
return {
|
||||||
|
"first_name": "Jane",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "jane.doe@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||||
|
|
||||||
|
oidc_backend = OIDCAuthenticationBackend()
|
||||||
|
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||||
|
|
||||||
|
assert result["first_name"] == "Jane"
|
||||||
|
assert result["last_name"] == "Doe"
|
||||||
|
assert result["email"] == "jane.doe@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||||
|
@responses.activate
|
||||||
|
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,
|
||||||
|
content_type="application/jwt",
|
||||||
|
)
|
||||||
|
|
||||||
|
oidc_backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
SuspiciousOperation,
|
||||||
|
match="User info response was not valid JWT",
|
||||||
|
):
|
||||||
|
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||||
|
django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
If an existing user matches the sub but is disabled,
|
||||||
|
an error should be raised and a user should not be created.
|
||||||
|
"""
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(is_active=False)
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {
|
return {
|
||||||
"test": "123",
|
"sub": db_user.sub,
|
||||||
|
"email": db_user.email,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
django_assert_num_queries(0),
|
django_assert_num_queries(1),
|
||||||
pytest.raises(
|
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||||
SuspiciousOperation,
|
|
||||||
match="User info contained no recognizable user identification",
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_getter_existing_disabled_user_via_email(
|
||||||
|
django_assert_num_queries, monkeypatch
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
If an existing user does not match the sub but matches the email and is disabled,
|
||||||
|
an error should be raised and a user should not be created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
klass = OIDCAuthenticationBackend()
|
||||||
|
db_user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
def get_userinfo_mocked(*args):
|
||||||
|
return {
|
||||||
|
"sub": "random",
|
||||||
|
"email": db_user.email,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
|
with (
|
||||||
|
django_assert_num_queries(2),
|
||||||
|
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||||
|
):
|
||||||
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
|
|
||||||
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_authentication_session_tokens(
|
||||||
|
django_assert_num_queries, monkeypatch, rf, settings
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
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 verify_token_mocked(*args, **kwargs):
|
||||||
|
return {"sub": "123", "email": "test@example.com"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||||
|
json={
|
||||||
|
"access_token": "test-access-token",
|
||||||
|
"refresh_token": "test-refresh-token",
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.authenticate(
|
||||||
|
request,
|
||||||
|
code="test-code",
|
||||||
|
nonce="test-nonce",
|
||||||
|
code_verifier="test-code-verifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user