Compare commits
728 commits
f/picture-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f699839c26 | |||
| 81a6aacbca | |||
| 6cf093db88 | |||
| 4adbf7839a | |||
| 095157d855 | |||
| 14b993196f | |||
| a113289461 | |||
| a33d667f3f | |||
| a99126f268 | |||
| 930195a6e9 | |||
| d2be8c8875 | |||
| 780f8af07e | |||
| af201ab897 | |||
| a9b79fd140 | |||
| b4450b03d2 | |||
| 6c13c6aae6 | |||
| 62d5d86459 | |||
| 1e732f53d7 | |||
| 8d5131508d | |||
| 10de670834 | |||
| cde844ed03 | |||
| 67ed637d18 | |||
| c65ded7859 | |||
| 7c345bf095 | |||
| ae42d381c8 | |||
| cade4de87f | |||
| 1e317fd4dc | |||
| ab1ad3930b | |||
| f06974cc80 | |||
| 02ced9e2c2 | |||
| 00ced139f3 | |||
| df918a9379 | |||
| 9b49cc5857 | |||
| b5e7e3cfd0 | |||
| f3adddd705 | |||
| ebc3f3257e | |||
| 11f02ad0c9 | |||
| 256233d002 | |||
| 2066a3119a | |||
| 54973bc27a | |||
| 0dfeef5075 | |||
| b1df56ed48 | |||
| bbca81003e | |||
| 5698c191bb | |||
| 66214b50d8 | |||
| 6b4108b7a0 | |||
| 072dbc9dd7 | |||
| ea3620db34 | |||
| d28f0fc335 | |||
| ab5b535f12 | |||
| 2d444d9098 | |||
| 2778cdd926 | |||
| 01c0795a5b | |||
| da3abc4540 | |||
| 0fb7232e1c | |||
| fcf252ab40 | |||
| d67a3d9aa7 | |||
| 8cc2bc37ad | |||
| 2a87a7ef9b | |||
| bcb4990956 | |||
| f13a6b03b8 | |||
| 6ed4b287a8 | |||
| 9536788a31 | |||
| 2141879718 | |||
| b45441e843 | |||
| 05350aeac4 | |||
| f13f25ce64 | |||
| e647e098fe | |||
| a5e190eecf | |||
| 5760a69d6b | |||
| d215595788 | |||
| ab40c7c000 | |||
| 6b3e38e205 | |||
| 49f42dd477 | |||
| f5c384564b | |||
| ae4d9484c3 | |||
| 54f0231f68 | |||
| f10d29a917 | |||
| 9939ecd7a3 | |||
| 7882ba83f5 | |||
| 4fefd67f6b | |||
| 5ae8d4e27a | |||
| 3b3c13771c | |||
| b4723dc66a | |||
| 2812b37947 | |||
| 919ceec689 | |||
| 81cbb6fd11 | |||
| ec1d40f469 | |||
| 637337aecf | |||
| e7f5062ba6 | |||
| 1cd9275c76 | |||
| eb3bd7d52a | |||
| dfb010df18 | |||
| 288d5b61dc | |||
| 8ed8783195 | |||
| b863d3b319 | |||
| a03f3dc0a2 | |||
| 1f40077b48 | |||
| 96768ab7ae | |||
| a721ad53f6 | |||
| b7bb28e80c | |||
| 9690795812 | |||
| 44f641bb58 | |||
| dfc011ca20 | |||
| d672baec43 | |||
| 07c2a3615c | |||
| 85871ac63c | |||
| 5179ebd1fb | |||
| a15661a661 | |||
| 15d99de98c | |||
| c6524825f2 | |||
| 3ed437c181 | |||
| 09473aa551 | |||
| 188aae4dd3 | |||
| bfe090ac50 | |||
| c373032830 | |||
| 3be7bf0e7c | |||
| 406c41856f | |||
| 40ea13e88d | |||
| 4dac5b527b | |||
| 301f77039f | |||
| 866c139e37 | |||
| 074d7b5782 | |||
| 6bcd211de0 | |||
| a4d47771ff | |||
| 337f4d975c | |||
| f2a82b9db6 | |||
| b6af81a8b6 | |||
| 6016ad8dcd | |||
| b4ff6314d6 | |||
| 1c4afb2461 | |||
| 8138af8e7c | |||
| 8630d66e64 | |||
| 30dd9103f1 | |||
| 8d2a08258d | |||
| b0d0c70cfc | |||
| 04cd4bc283 | |||
| 586a89ee90 | |||
| bf78306e98 | |||
| e1eb999e70 | |||
| 67d6b9a805 | |||
| 2e92a65968 | |||
| 2dc34d43a7 | |||
| f047f72ad1 | |||
| 749ec9a41e | |||
| 5326a37f8c | |||
| c2aee54a6b | |||
| 4f211afe50 | |||
| f38e44802d | |||
| 58e90e640d | |||
| 8e9c4faf45 | |||
| fc2952289d | |||
| d0cd58e365 | |||
| 44bb3d1713 | |||
| a77cca6278 | |||
| 4ed9507c76 | |||
| 84cc469b99 | |||
| 3f8dd8edd7 | |||
| 3ee467d0b9 | |||
| 7362168eff | |||
| 73190dcd3f | |||
| 523873ae9d | |||
| d5c483fc0c | |||
| 0d0f76fc38 | |||
| 33b6400754 | |||
| 37729609b4 | |||
| 83a171e9b9 | |||
| bb0525521e | |||
| 4e056d08f2 | |||
| 2f3e9b82c7 | |||
| 84ff753582 | |||
| a7d31c6b92 | |||
| d20941931a | |||
| e6c1e92319 | |||
| a1f3b374eb | |||
| 6aa9d131d6 | |||
| 2be7460a7b | |||
| 1c97e8ff74 | |||
| ec2438a6fe | |||
| beaf39dba1 | |||
| 26c3f16f7a | |||
| d598a9c6ef | |||
| e5b2812626 | |||
| 97e874dc7d | |||
| d5d86d0f56 | |||
| f553144c6e | |||
| 7586cf0f14 | |||
| 0310d4d82a | |||
| 329459af7e | |||
| 769d56a42e | |||
| eac92a200c | |||
| f096cd686a | |||
| 611846fd17 | |||
| 9969c0fd95 | |||
| ddb4f44892 | |||
| 26464ebd20 | |||
| b0ae313de9 | |||
| 403a0d4288 | |||
| 6a06290359 | |||
| d34bcf1e8f | |||
| 31a4547e3b | |||
| bf6bee03fb | |||
| 0b7b545352 | |||
| 2f2fe68b96 | |||
| 91dbf01408 | |||
| a3e665c435 | |||
| 912997835a | |||
| 3ace06da4f | |||
| 6f1b9e68cb | |||
| a3c1317bae | |||
| 072507ab92 | |||
| 4bbf8f6d03 | |||
| d2d03d863e | |||
| bf8e196b36 | |||
| 3866dd9880 | |||
| 917895adc9 | |||
| 780f05e05d | |||
| 01375096f8 | |||
| 0a36c7ad3c | |||
| a0cac2c577 | |||
| 5b3ab94c3e | |||
| a418c89672 | |||
| e92130b998 | |||
| 8a3f7ba587 | |||
| dfc541011c | |||
| 0e8d0d5d71 | |||
| c44be1a9c2 | |||
| 229bf88646 | |||
| 5f43d21159 | |||
| c758413542 | |||
| f8dacb1ffc | |||
| 8694988327 | |||
| 53ed4d7904 | |||
| fa8c5d43dd | |||
| 6fea42006a | |||
| 3f76f680be | |||
| d26c33522c | |||
| aa9a75e3cb | |||
| 6a99c23218 | |||
| cccaacc606 | |||
| b024e0abba | |||
| 7d19091aa1 | |||
| e3854a46ab | |||
| a09028978d | |||
| 4a8ce4c464 | |||
| 5538804baf | |||
| 32b9393a77 | |||
| bddd8bacf6 | |||
| 5b7f120280 | |||
| 26dcff2728 | |||
| d0b7922169 | |||
| 7528e5fa61 | |||
| 79464b201c | |||
| 4be1496f40 | |||
| d6d0d747b7 | |||
| a19c246cb5 | |||
| 9a25d1473f | |||
| ffec258b12 | |||
| 80da7fefe6 | |||
| 9376eba6ae | |||
| a27d9bb0ce | |||
| ead4458938 | |||
| db4c69e45c | |||
| a8c9497cd3 | |||
| a2d5dae4de | |||
| 6dedf2d4aa | |||
| a1aa4b0b2c | |||
| fea39b4c34 | |||
| 1c4b3ed253 | |||
| e8ffe5f6aa | |||
| 97f27ce914 | |||
| 84d9409411 | |||
| fa07dd2e29 | |||
| 1715bc7cb7 | |||
| 808f3a2783 | |||
| 85c39b4547 | |||
| abfcd2fd27 | |||
| f75a13b75e | |||
| 9f8b7da0cc | |||
| 57cd7c8198 | |||
| 5e54ad3450 | |||
| 96c7c85ca3 | |||
| 1850fdca83 | |||
| 5a8cc3677a | |||
| fdb3fb9992 | |||
| 9522fed68f | |||
| a94ca6ad1d | |||
| 50b61b17ec | |||
| 7f98702102 | |||
| 7fdd25624e | |||
| 6948b91bd5 | |||
| e5dcb10429 | |||
| 440fffb84b | |||
| 23501dc139 | |||
| c5d20c8fed | |||
| f1f013a784 | |||
| 02f9131d6b | |||
| fff8f3dad4 | |||
| df4da19589 | |||
| 71576af4d7 | |||
| 273eeaf753 | |||
| 3facc41327 | |||
| aeccd4b50d | |||
| 540b67841c | |||
| ec804681a5 | |||
| 6634f30b50 | |||
| 622ed2df63 | |||
| 6e86f181d4 | |||
| bb9f6ddb5d | |||
| cf46d8a7cb | |||
| c5ccd71de5 | |||
| 71910b9da8 | |||
| c63a210ea7 | |||
| 0cca11e1da | |||
| 36441fcf85 | |||
| ff8ad740c3 | |||
| b8859595fc | |||
| 86b4e45320 | |||
| 6d2a2d6c3a | |||
| cc7b772b2a | |||
| b5ec9ba012 | |||
| e975a006ca | |||
| b078d648bc | |||
| 180b6eaf0a | |||
| 8bebc2770f | |||
| eabd7a89a4 | |||
| c5bf52801a | |||
| af02b73541 | |||
| 5b0a8a405b | |||
| a3aa1a6e1b | |||
| 1be761d4b0 | |||
| 673ccba290 | |||
| 0d63b3d8d1 | |||
| 5704bab1fd | |||
| b43c009638 | |||
| 2a5af7e978 | |||
| 61c0db2a26 | |||
| c16b3cb16f | |||
| 81489d7a2d | |||
| 5a8a4f6796 | |||
| 9e2440cb3a | |||
| 1047959c72 | |||
| 1a7eb222ed | |||
| f2a99495b3 | |||
| 9109e14693 | |||
| faf747a90b | |||
| acc4a78338 | |||
| 95c3d9baa1 | |||
| 5814dd4d9e | |||
| 69ad134c5c | |||
| 89e2c8b363 | |||
| 3503e0fcc1 | |||
| d3f41fc8a3 | |||
| 69863b34ba | |||
| c633c2bdad | |||
| b088ca44c0 | |||
| d1a25a9062 | |||
| ad7b7807f9 | |||
| 504956847e | |||
| d04988e8a4 | |||
| b57d95ff31 | |||
| e957fd8ad6 | |||
| 032d21d01f | |||
| 2f804749d9 | |||
| d98fd94873 | |||
| fb427c4ff6 | |||
| 0b6c6e626c | |||
| f0d021c1c0 | |||
| ff8204ce51 | |||
| 1d3f78eae7 | |||
| a6b5e569a3 | |||
| b7fb5506f5 | |||
| 2e1d97395d | |||
| 058a77cd5a | |||
| dcc2135b1f | |||
| 3301d89889 | |||
| 7ad2d5a61a | |||
| 345d3cc6e7 | |||
| dcf18dc750 | |||
| da7e352d0a | |||
| 41f6681696 | |||
| e7bc9e874e | |||
| db026b8777 | |||
| 22f3c9094b | |||
| bc05e807ee | |||
| 316b0c9ced | |||
| 99093aacce | |||
| 0a38c6a2bb | |||
| bdbf76cfab | |||
| 37eb2001bd | |||
| cc8e7e5885 | |||
| 1377896d9c | |||
| db1aac506c | |||
| e81d835813 | |||
| d3b1906113 | |||
| e31842e37a | |||
| cade7bbb2c | |||
| e2424bcd0a | |||
| 4a43fdc7ba | |||
| 2f34b2fd86 | |||
| 3e4ce3f783 | |||
| 6c2164748f | |||
| ce0ec15652 | |||
| 775a012340 | |||
| 86516e7624 | |||
| b3203fe889 | |||
| c4c7605eb0 | |||
| 1fe89efd37 | |||
| bb54322bb1 | |||
| e9625f07c4 | |||
| 7935145094 | |||
| 8cecf6aeba | |||
| d73d2be0c6 | |||
| 335a7e6229 | |||
| f7eed34bdb | |||
| a3c549d9e8 | |||
| 9d12fc8b20 | |||
| 83e180f5a2 | |||
| 4ce40ca340 | |||
| b143a8ddbf | |||
| 518ccb8cbd | |||
| 14c1dc819e | |||
| 608da24d48 | |||
| f8a4857460 | |||
| f4b28e0818 | |||
| fb1dcc7b6f | |||
| 05ca15a234 | |||
| 384c5a37b9 | |||
| b9f7435192 | |||
| a4512d4ce1 | |||
| aed9baf100 | |||
| c676c7d462 | |||
| 490df34933 | |||
| c3c4b32fdf | |||
| 706294a16c | |||
| f3fcf0af1f | |||
| 7cb1776f1e | |||
| f4a7769b55 | |||
| 4d34d0fd0f | |||
| f0f8f7a972 | |||
| 2fa1faf0f3 | |||
| 354f405380 | |||
| 74b8a7d7df | |||
| 93df8c4385 | |||
| d75f56efc8 | |||
| d15179372a | |||
| 5c41db3a9d | |||
| 466c2f53ae | |||
| 7809d1c738 | |||
| e44c09f12b | |||
| 38a0bab18f | |||
| b9c30a38fd | |||
| 73904a88c5 | |||
| 01d011e11c | |||
| 7aa0991a94 | |||
| 48e4fe2f31 | |||
| 39b719f9ec | |||
| 198f264d77 | |||
| a17835c5af | |||
| b3c2f2adb0 | |||
| 730c21f4fe | |||
| 57f1beacab | |||
| 2b14688d6c | |||
| e81e88367e | |||
| e20ceb4701 | |||
| 45d4311046 | |||
| 02f6f9d12a | |||
| 9d4fd8c3a1 | |||
| 4ac8937e0f | |||
| f96561998c | |||
| 0c17ff1165 | |||
| 552fd6ce8a | |||
| 7ab55a0793 | |||
| 2745dbc6ab | |||
| a86123ffa9 | |||
| 82d2141811 | |||
| 975fadf31c | |||
| db731e5807 | |||
| b4c3577367 | |||
| ca9f680f72 | |||
| 4cc2a677cb | |||
| ac9a76d0b2 | |||
| 1959c50eb1 | |||
| c5f8132a33 | |||
| 6b17e03a70 | |||
| d0de6af32a | |||
| 75c8ea9e6c | |||
| c8b491f1e4 | |||
| 30e59a6fd7 | |||
| 9b50516bac | |||
| 08bc95d6a1 | |||
| e5aa1b6c2f | |||
| 79814fba21 | |||
| 1ab2df8228 | |||
| d0d5b835a7 | |||
| 8a6e5e70f0 | |||
| 59e11e479b | |||
| 8f20909dc3 | |||
| 35981edf56 | |||
| 04f2eceec3 | |||
| c288c962e1 | |||
| 65fe9f57b4 | |||
| a53788f79c | |||
| c529036681 | |||
| 2f2f80fa1b | |||
| ba6e1490c3 | |||
| a2a2f7dc87 | |||
| b897c4ac68 | |||
| e6512f3910 | |||
| b7cdb53c72 | |||
| 836da2ae30 | |||
| db253aec61 | |||
| 3caf5b2597 | |||
| 85c52afba1 | |||
| 30edb5c5c6 | |||
| f4975a7154 | |||
| 528a07376a | |||
| 7b93dedfb7 | |||
| eabb32f88b | |||
| df456a0bdb | |||
| 70e2188bb2 | |||
| e0a9b85583 | |||
| 9beeb5d8ff | |||
| ee93d9de41 | |||
| 255065e466 | |||
| 006727a97a | |||
| 1516566376 | |||
| 9e23e7a563 | |||
| cb86fc8937 | |||
| 65720a682b | |||
| d1649b5b4f | |||
| 28a162c19e | |||
| 66d7778c10 | |||
| fe15719850 | |||
| 59f3cdb52a | |||
| 0d6e546399 | |||
| 3750c30d99 | |||
| 19a759536b | |||
| 9ad021fa65 | |||
| 49dd674f72 | |||
| 1348678be1 | |||
| 0965698c90 | |||
| a9bb758e99 | |||
| 93b5857fa6 | |||
| 2ee8901d8c | |||
| e90d55c07e | |||
| 522fe05e99 | |||
| 9032d53ae3 | |||
| 61dde4e7e3 | |||
| 23aa993671 | |||
| 3af70cf678 | |||
| 1f4ce865aa | |||
| 7642a23947 | |||
| b604e98f64 | |||
| f7da603dbe | |||
| 07ceb20d63 | |||
| 6c471b5ec1 | |||
| 562cd5b397 | |||
| 6fed13ee0e | |||
| b1ad293810 | |||
| 2129ba46ee | |||
| 18225ca1d6 | |||
| 0836486e3e | |||
| db5658ccc1 | |||
| 018ed9227f | |||
| 1f7896ff26 | |||
| 5599a94f18 | |||
| cd500afbd5 | |||
| 3397b9f123 | |||
| 107b17c11f | |||
| 51a311f0b9 | |||
| 0b192e4783 | |||
| cc729227ce | |||
| 6cd3907dc3 | |||
| 184daa4cab | |||
| 1515140c09 | |||
| 9a145965fb | |||
| bcf76a2c86 | |||
| 8b8f3947f8 | |||
| a78de73671 | |||
| 906501cc7b | |||
| 5020f378c8 | |||
| ce0c8e0025 | |||
| 64eb8f2852 | |||
| 0620d9d834 | |||
| 61f4795511 | |||
| 084244449c | |||
| 706e786190 | |||
| 6f9b83ef24 | |||
| 936a8a80f4 | |||
| 91aee60bfb | |||
| e57b7a7089 | |||
| e61a8bd51d | |||
| 5b2fddddc1 | |||
| ce1d61b6c4 | |||
| 99a1e3c111 | |||
| 8b1d7cc5fc | |||
| 88f47cbae9 | |||
| 4987ecd174 | |||
| 61ce4e5c1d | |||
| 4fb7b768e1 | |||
| 9bb8fe513a | |||
| 6478b2fce8 | |||
| 2a45835a08 | |||
| e7cadf67f3 | |||
| 0afce9a789 | |||
| 020f171e36 | |||
| 6f332b2e2f | |||
| f3f7c55be3 | |||
| e7a7ad407c | |||
| 460b1a9381 | |||
| 3e839be0db | |||
| 04fba5b199 | |||
| 8279b1dc98 | |||
| db203f8031 | |||
| 5ab23024b6 | |||
| 9670861e3f | |||
| e90705562e | |||
| 1f632ed381 | |||
| 8c64ac6bab | |||
| 898dfde3ad | |||
| 96b0df19fa | |||
| b76dc7ec9f | |||
| 4045308694 | |||
| d54e78874a | |||
| 87f23aa3fe | |||
| 09298727e6 | |||
| 994ef52a0c | |||
| 6fb8ea3ae5 | |||
| 58ebeb3272 | |||
| 8f367ddffe | |||
| c09103fd2c | |||
| d435dfb46c | |||
| 3924415998 | |||
| f0986e7c83 | |||
| 9cdc7fd7d6 | |||
| f57ab66f0c | |||
| 7fd2b29551 | |||
| f5d96a6837 | |||
| 9353de70e1 | |||
| 9e9d31f850 | |||
| fd07d9e434 | |||
| 4bb801ca37 | |||
| 24d44eaa61 | |||
| 74292ffff3 | |||
| 89cfee335a | |||
| 77905b912b | |||
| d1102923a5 | |||
| 0026e4ebd2 | |||
| 99b7fad056 | |||
| 9e956edb57 | |||
| 735a120259 | |||
| 7340e10a7a | |||
| ec71073cb6 | |||
| 6def3de983 | |||
| e8d34869cc | |||
| 716aa39827 | |||
| 14afe26e5a | |||
| 5b528e4e89 | |||
| 9089e4877d | |||
| 95c221ae49 | |||
| e069a29292 | |||
| 366768abde | |||
| 3063b1aa88 | |||
| f592b8a1b5 | |||
| 47121bb3d3 | |||
| 289702a341 | |||
| 8ed2526f97 | |||
| 96466dedf7 | |||
| 24c53afad6 | |||
| c36119d49a | |||
| dde1f825cc | |||
| c692e34fdf | |||
| ba5b8570b7 | |||
| 78ea97d1d4 | |||
| 0bcedc61d9 | |||
| e9c450742b | |||
| ff5a2eef65 | |||
| 4d6149760d | |||
| 6d9cbdf048 | |||
| 34d5ddfbaa | |||
| 1ce2906d72 | |||
| a969508aa3 | |||
| 546cbf9ab5 | |||
| c69d6bf6db | |||
| c93e644288 | |||
| 5b81ca412b | |||
| f2ab869259 | |||
| 9d8981456e | |||
| fff8b821c5 | |||
| 9fd73ce235 | |||
| ad0d12e67a | |||
| 9102eae561 | |||
| 3b716d73c7 | |||
| c5a75b6fa8 | |||
| 492dd16ed9 | |||
| e345e967d6 | |||
| f9ba0c0a60 | |||
| 2fc35c1a75 | |||
| c7059671cb | |||
| 8f5e2f380f | |||
| 0cab193d6c | |||
| 4be5503af9 | |||
| 749d55c24f | |||
| ee4872ff38 | |||
| 1c7803169d | |||
| b4ef09dd5b | |||
| ce6141e6e0 | |||
| 4c76dd9728 | |||
| b88d284859 | |||
| f7d747cd76 | |||
| 6a54315626 | |||
| 1532ede587 | |||
| c21df9d93f | |||
| f675047ce8 | |||
| bf5b0e88dd | |||
| d7f679ce84 | |||
| d6f620bc0d | |||
| 10d0a6a836 | |||
| 4ebcc890dc | |||
| 55700ce103 | |||
| 4bf8cce899 | |||
| 5cc07a4c70 | |||
| 140935e15d | |||
| 8fd9908d59 | |||
| 12d531873c | |||
| b6d17c2eb2 | |||
| b4b531409f |
132 changed files with 8615 additions and 5859 deletions
53
.drone.yml
53
.drone.yml
|
|
@ -1,56 +1,6 @@
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: build-arm
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: linux
|
|
||||||
arch: arm
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build front
|
|
||||||
image: node:19-alpine
|
|
||||||
commands:
|
|
||||||
- mkdir deploy
|
|
||||||
- cd ui
|
|
||||||
- npm install --network-timeout=100000
|
|
||||||
- npm run build
|
|
||||||
- tar chjf ../deploy/static.tar.bz2 build
|
|
||||||
|
|
||||||
- name: vet
|
|
||||||
image: golang:1-alpine
|
|
||||||
commands:
|
|
||||||
- apk --no-cache add build-base
|
|
||||||
- go vet -v -buildvcs=false
|
|
||||||
|
|
||||||
- name: backend armv7
|
|
||||||
image: golang:1-alpine
|
|
||||||
commands:
|
|
||||||
- apk --no-cache add build-base
|
|
||||||
- go get -v
|
|
||||||
- go build -v -buildvcs=false -ldflags="-s -w"
|
|
||||||
environment:
|
|
||||||
GOARM: 7
|
|
||||||
|
|
||||||
- name: publish
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
repo: nemunaire/atsebay.t
|
|
||||||
auto_tag: true
|
|
||||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- cron
|
|
||||||
- push
|
|
||||||
- tag
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: build-arm64
|
name: build-arm64
|
||||||
|
|
||||||
platform:
|
platform:
|
||||||
|
|
@ -59,7 +9,7 @@ platform:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build front
|
- name: build front
|
||||||
image: node:19-alpine
|
image: node:21-alpine
|
||||||
commands:
|
commands:
|
||||||
- mkdir deploy
|
- mkdir deploy
|
||||||
- cd ui
|
- cd ui
|
||||||
|
|
@ -119,5 +69,4 @@ trigger:
|
||||||
- tag
|
- tag
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-arm
|
|
||||||
- build-arm64
|
- build-arm64
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:19-alpine as nodebuild
|
FROM node:21-alpine as nodebuild
|
||||||
|
|
||||||
WORKDIR /ui
|
WORKDIR /ui
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ RUN go get -d -v && \
|
||||||
go build -v -buildvcs=false -ldflags="-s -w" -o atsebay.t
|
go build -v -buildvcs=false -ldflags="-s -w" -o atsebay.t
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:3.16
|
FROM alpine:3.19
|
||||||
|
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
|
|
||||||
|
|
|
||||||
4
api.go
4
api.go
|
|
@ -13,9 +13,11 @@ func declareAPIRoutes(router *gin.Engine) {
|
||||||
apiRoutes.Use(authMiddleware())
|
apiRoutes.Use(authMiddleware())
|
||||||
|
|
||||||
declareAPIAuthRoutes(apiRoutes)
|
declareAPIAuthRoutes(apiRoutes)
|
||||||
|
declareAPICategoriesRoutes(apiRoutes)
|
||||||
declareAPISurveysRoutes(apiRoutes)
|
declareAPISurveysRoutes(apiRoutes)
|
||||||
declareAPIWorksRoutes(apiRoutes)
|
declareAPIWorksRoutes(apiRoutes)
|
||||||
declareAPIKeysRoutes(apiRoutes)
|
declareAPIKeysRoutes(apiRoutes)
|
||||||
|
declareAPISharesRoutes(apiRoutes)
|
||||||
declareCallbacksRoutes(apiRoutes)
|
declareCallbacksRoutes(apiRoutes)
|
||||||
|
|
||||||
authRoutes := router.Group("")
|
authRoutes := router.Group("")
|
||||||
|
|
@ -50,7 +52,9 @@ func declareAPIRoutes(router *gin.Engine) {
|
||||||
|
|
||||||
declareAPIAdminAuthRoutes(apiAdminRoutes)
|
declareAPIAdminAuthRoutes(apiAdminRoutes)
|
||||||
declareAPIAdminAsksRoutes(apiAdminRoutes)
|
declareAPIAdminAsksRoutes(apiAdminRoutes)
|
||||||
|
declareAPIAdminCategoriesRoutes(apiRoutes)
|
||||||
declareAPIAuthGradesRoutes(apiAdminRoutes)
|
declareAPIAuthGradesRoutes(apiAdminRoutes)
|
||||||
|
declareAPIAdminGradationRoutes(apiAdminRoutes)
|
||||||
declareAPIAdminHelpRoutes(apiAdminRoutes)
|
declareAPIAdminHelpRoutes(apiAdminRoutes)
|
||||||
declareAPIAdminQuestionsRoutes(apiAdminRoutes)
|
declareAPIAdminQuestionsRoutes(apiAdminRoutes)
|
||||||
declareAPIAuthRepositoriesRoutes(apiAdminRoutes)
|
declareAPIAuthRepositoriesRoutes(apiAdminRoutes)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed ui/build/* ui/build/css/* ui/build/surveys/* ui/build/_app/* ui/build/_app/immutable/* ui/build/_app/immutable/pages/* ui/build/_app/immutable/pages/surveys/* ui/build/_app/immutable/pages/surveys/_sid_/* ui/build/_app/immutable/pages/surveys/_sid_/responses/* ui/build/_app/immutable/pages/grades/* ui/build/_app/immutable/pages/works/* ui/build/_app/immutable/pages/works/_wid_/* ui/build/_app/immutable/pages/users/* ui/build/_app/immutable/pages/users/_uid_/* ui/build/_app/immutable/pages/users/_uid_/surveys/* ui/build/_app/immutable/chunks/* ui/build/_app/immutable/assets/* ui/build/img/* ui/build/works/*
|
//go:embed all:ui/build
|
||||||
var _assets embed.FS
|
var _assets embed.FS
|
||||||
|
|
||||||
var Assets http.FileSystem
|
var Assets http.FileSystem
|
||||||
|
|
|
||||||
29
auth.go
29
auth.go
|
|
@ -13,6 +13,7 @@ import (
|
||||||
var LocalAuthFunc = checkAuthKrb5
|
var LocalAuthFunc = checkAuthKrb5
|
||||||
var allowLocalAuth bool
|
var allowLocalAuth bool
|
||||||
var localAuthUsers arrayFlags
|
var localAuthUsers arrayFlags
|
||||||
|
var mainBanner string
|
||||||
|
|
||||||
type loginForm struct {
|
type loginForm struct {
|
||||||
Login string `json:"username"`
|
Login string `json:"username"`
|
||||||
|
|
@ -47,16 +48,18 @@ func declareAPIAdminAuthRoutes(router *gin.RouterGroup) {
|
||||||
session.Update()
|
session.Update()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, authToken{
|
c.JSON(http.StatusOK, authToken{
|
||||||
User: newuser,
|
User: newuser,
|
||||||
CurrentPromo: currentPromo,
|
CurrentPromo: currentPromo,
|
||||||
|
MessageBanner: mainBanner,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type authToken struct {
|
type authToken struct {
|
||||||
*User
|
*User
|
||||||
CurrentPromo uint `json:"current_promo"`
|
CurrentPromo uint `json:"current_promo"`
|
||||||
Groups []string `json:"groups"`
|
Groups []string `json:"groups"`
|
||||||
|
MessageBanner string `json:"banner,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAuthToken(c *gin.Context) {
|
func validateAuthToken(c *gin.Context) {
|
||||||
|
|
@ -64,7 +67,7 @@ func validateAuthToken(c *gin.Context) {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not connected"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not connected"})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
t := authToken{User: u.(*User), CurrentPromo: currentPromo}
|
t := authToken{User: u.(*User), CurrentPromo: currentPromo, MessageBanner: mainBanner}
|
||||||
|
|
||||||
t.Groups = strings.Split(strings.TrimFunc(t.User.Groups, func(r rune) bool { return !unicode.IsLetter(r) }), ",")
|
t.Groups = strings.Split(strings.TrimFunc(t.User.Groups, func(r rune) bool { return !unicode.IsLetter(r) }), ",")
|
||||||
|
|
||||||
|
|
@ -77,7 +80,7 @@ func logout(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, true)
|
c.JSON(http.StatusOK, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeAuth(c *gin.Context, username string, email string, firstname string, lastname string, promo uint, groups string, face_url string, session *Session) (usr *User, err error) {
|
func completeAuth(c *gin.Context, username string, email string, firstname string, lastname string, promo uint, groups string, session *Session) (usr *User, err error) {
|
||||||
if !userExists(username) {
|
if !userExists(username) {
|
||||||
if promo == 0 {
|
if promo == 0 {
|
||||||
promo = currentPromo
|
promo = currentPromo
|
||||||
|
|
@ -114,14 +117,10 @@ func completeAuth(c *gin.Context, username string, email string, firstname strin
|
||||||
|
|
||||||
if session == nil {
|
if session == nil {
|
||||||
session, err = usr.NewSession()
|
session, err = usr.NewSession()
|
||||||
if err != nil {
|
} else {
|
||||||
return
|
_, err = session.SetUser(usr)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if face_url != "" {
|
|
||||||
session.SetKey("picture", face_url)
|
|
||||||
}
|
|
||||||
_, err = session.SetUser(usr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -157,10 +156,10 @@ func dummyAuth(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if usr, err := completeAuth(c, lf["username"], lf["email"], lf["firstname"], lf["lastname"], currentPromo, "", "", nil); err != nil {
|
if usr, err := completeAuth(c, lf["username"], lf["email"], lf["firstname"], lf["lastname"], currentPromo, "", nil); err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusOK, authToken{User: usr, CurrentPromo: currentPromo})
|
c.JSON(http.StatusOK, authToken{User: usr, CurrentPromo: currentPromo, MessageBanner: mainBanner})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ func checkAuthKrb5(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if usr, err := completeAuth(c, lf.Login, lf.Login+"@epita.fr", "", "", currentPromo, "", "", nil); err != nil {
|
if usr, err := completeAuth(c, lf.Login, lf.Login+"@epita.fr", "", "", 0, "", nil); err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func initializeOIDC(router *gin.Engine) {
|
||||||
Endpoint: provider.Endpoint(),
|
Endpoint: provider.Endpoint(),
|
||||||
|
|
||||||
// "openid" is a required scope for OpenID Connect flows.
|
// "openid" is a required scope for OpenID Connect flows.
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "epita", "picture"},
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "epita"},
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfig := oidc.Config{
|
oidcConfig := oidc.Config{
|
||||||
|
|
@ -112,9 +112,6 @@ func OIDC_CRI_complete(c *gin.Context) {
|
||||||
Groups []map[string]interface{} `json:"groups"`
|
Groups []map[string]interface{} `json:"groups"`
|
||||||
Campuses []string `json:"campuses"`
|
Campuses []string `json:"campuses"`
|
||||||
GraduationYears []uint `json:"graduation_years"`
|
GraduationYears []uint `json:"graduation_years"`
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureSquare string `json:"picture_square"`
|
|
||||||
PictureThumb string `json:"picture_thumb"`
|
|
||||||
}
|
}
|
||||||
if err := idToken.Claims(&claims); err != nil {
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
log.Println("Unable to extract claims to Claims:", err.Error())
|
log.Println("Unable to extract claims to Claims:", err.Error())
|
||||||
|
|
@ -138,7 +135,7 @@ func OIDC_CRI_complete(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := completeAuth(c, claims.Username, claims.Email, claims.Firstname, claims.Lastname, promo, groups, claims.PictureSquare, session); err != nil {
|
if _, err := completeAuth(c, claims.Username, claims.Email, claims.Firstname, claims.Lastname, promo, groups, session); err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
categories.go
Normal file
168
categories.go
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func declareAPICategoriesRoutes(router *gin.RouterGroup) {
|
||||||
|
categoriesRoutes := router.Group("/categories/:cid")
|
||||||
|
categoriesRoutes.Use(categoryHandler)
|
||||||
|
|
||||||
|
categoriesRoutes.GET("", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, c.MustGet("category").(*Category))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func declareAPIAdminCategoriesRoutes(router *gin.RouterGroup) {
|
||||||
|
router.GET("categories", func(c *gin.Context) {
|
||||||
|
categories, err := getCategories()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to getCategories:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve categories. Please try again later."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, categories)
|
||||||
|
})
|
||||||
|
router.POST("categories", func(c *gin.Context) {
|
||||||
|
var new Category
|
||||||
|
if err := c.ShouldBindJSON(&new); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if new.Promo == 0 {
|
||||||
|
new.Promo = currentPromo
|
||||||
|
}
|
||||||
|
|
||||||
|
if cat, err := NewCategory(new.Label, new.Promo, new.Expand); err != nil {
|
||||||
|
log.Println("Unable to NewCategory:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during category creation: %s", err.Error())})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, cat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
categoriesRoutes := router.Group("/categories/:cid")
|
||||||
|
categoriesRoutes.Use(categoryHandler)
|
||||||
|
|
||||||
|
categoriesRoutes.PUT("", func(c *gin.Context) {
|
||||||
|
current := c.MustGet("category").(*Category)
|
||||||
|
|
||||||
|
var new Category
|
||||||
|
if err := c.ShouldBindJSON(&new); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
new.Id = current.Id
|
||||||
|
|
||||||
|
if _, err := new.Update(); err != nil {
|
||||||
|
log.Println("Unable to Update category:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to update the given category. Please try again later."})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, new)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
categoriesRoutes.DELETE("", func(c *gin.Context) {
|
||||||
|
current := c.MustGet("category").(*Category)
|
||||||
|
|
||||||
|
if _, err := current.Delete(); err != nil {
|
||||||
|
log.Println("Unable to Delete category:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to delete the given category. Please try again later."})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryHandler(c *gin.Context) {
|
||||||
|
var category *Category
|
||||||
|
|
||||||
|
cid, err := strconv.Atoi(string(c.Param("cid")))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad category identifier."})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
category, err = getCategory(cid)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Category not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("category", category)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Promo uint `json:"promo"`
|
||||||
|
Expand bool `json:"expand,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategories() (categories []Category, err error) {
|
||||||
|
if rows, errr := DBQuery("SELECT id_category, label, promo, expand FROM categories ORDER BY promo DESC, expand DESC, id_category DESC"); errr != nil {
|
||||||
|
return nil, errr
|
||||||
|
} else {
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var c Category
|
||||||
|
if err = rows.Scan(&c.Id, &c.Label, &c.Promo, &c.Expand); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
categories = append(categories, c)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategory(id int) (c *Category, err error) {
|
||||||
|
c = new(Category)
|
||||||
|
err = DBQueryRow("SELECT id_category, label, promo, expand FROM categories WHERE id_category=?", id).Scan(&c.Id, &c.Label, &c.Promo, &c.Expand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategory(label string, promo uint, expand bool) (*Category, error) {
|
||||||
|
if res, err := DBExec("INSERT INTO categories (label, promo, expand) VALUES (?, ?, ?)", label, promo, expand); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if cid, err := res.LastInsertId(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return &Category{cid, label, promo, expand}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Category) Update() (int64, error) {
|
||||||
|
if res, err := DBExec("UPDATE categories SET label = ?, promo = ?, expand = ? WHERE id_category = ?", c.Label, c.Promo, c.Expand, c.Id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else if nb, err := res.RowsAffected(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else {
|
||||||
|
return nb, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Category) Delete() (int64, error) {
|
||||||
|
if res, err := DBExec("DELETE FROM categories WHERE id_category = ?", c.Id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else if nb, err := res.RowsAffected(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else {
|
||||||
|
return nb, err
|
||||||
|
}
|
||||||
|
}
|
||||||
36
db.go
36
db.go
|
|
@ -93,6 +93,7 @@ CREATE TABLE IF NOT EXISTS user_keys(
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS surveys(
|
CREATE TABLE IF NOT EXISTS surveys(
|
||||||
id_survey INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
id_survey INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
id_category INTEGER NOT NULL,
|
||||||
title VARCHAR(255),
|
title VARCHAR(255),
|
||||||
promo MEDIUMINT NOT NULL,
|
promo MEDIUMINT NOT NULL,
|
||||||
grp VARCHAR(255) NOT NULL,
|
grp VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -100,7 +101,8 @@ CREATE TABLE IF NOT EXISTS surveys(
|
||||||
direct INTEGER DEFAULT NULL,
|
direct INTEGER DEFAULT NULL,
|
||||||
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(id_category) REFERENCES categories(id_category)
|
||||||
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -200,6 +202,7 @@ CREATE TABLE IF NOT EXISTS user_need_help(
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS works(
|
CREATE TABLE IF NOT EXISTS works(
|
||||||
id_work INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
id_work INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
id_category INTEGER NOT NULL,
|
||||||
title VARCHAR(255),
|
title VARCHAR(255),
|
||||||
promo MEDIUMINT NOT NULL,
|
promo MEDIUMINT NOT NULL,
|
||||||
grp VARCHAR(255) NOT NULL,
|
grp VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -207,9 +210,11 @@ CREATE TABLE IF NOT EXISTS works(
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
tag VARCHAR(255) NOT NULL,
|
tag VARCHAR(255) NOT NULL,
|
||||||
submission_URL VARCHAR(255) NULL,
|
submission_URL VARCHAR(255) NULL,
|
||||||
|
gradation_repo VARCHAR(255) NULL,
|
||||||
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(id_category) REFERENCES categories(id_category)
|
||||||
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -237,6 +242,8 @@ CREATE TABLE IF NOT EXISTS user_work_repositories(
|
||||||
secret BLOB NOT NULL,
|
secret BLOB NOT NULL,
|
||||||
last_check TIMESTAMP NULL DEFAULT NULL,
|
last_check TIMESTAMP NULL DEFAULT NULL,
|
||||||
droneref VARCHAR(255) NOT NULL,
|
droneref VARCHAR(255) NOT NULL,
|
||||||
|
last_tests TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
testsref VARCHAR(255) NOT NULL,
|
||||||
FOREIGN KEY(id_user) REFERENCES users(id_user),
|
FOREIGN KEY(id_user) REFERENCES users(id_user),
|
||||||
FOREIGN KEY(id_work) REFERENCES works(id_work),
|
FOREIGN KEY(id_work) REFERENCES works(id_work),
|
||||||
UNIQUE one_repo_per_work (id_user, id_work)
|
UNIQUE one_repo_per_work (id_user, id_work)
|
||||||
|
|
@ -245,12 +252,33 @@ CREATE TABLE IF NOT EXISTS user_work_repositories(
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
CREATE VIEW IF NOT EXISTS student_scores AS SELECT T.id_user, T.id_survey, Q.id_question, MAX(R.score) AS score FROM (SELECT DISTINCT R.id_user, S.id_survey FROM survey_responses R INNER JOIN survey_quests Q ON R.id_question = Q.id_question INNER JOIN surveys S ON Q.id_survey = S.id_survey) T LEFT OUTER JOIN survey_quests Q ON T.id_survey = Q.id_survey LEFT OUTER JOIN survey_responses R ON R.id_user = T.id_user AND Q.id_question = R.id_question GROUP BY id_user, id_survey, id_question;
|
CREATE TABLE IF NOT EXISTS categories(
|
||||||
|
id_category INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
promo MEDIUMINT NOT NULL,
|
||||||
|
expand BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys;
|
CREATE TABLE IF NOT EXISTS survey_shared(
|
||||||
|
id_share INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
id_survey INTEGER NOT NULL,
|
||||||
|
secret BLOB NOT NULL,
|
||||||
|
count MEDIUMINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(id_survey) REFERENCES surveys(id_survey)
|
||||||
|
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
|
||||||
|
`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE VIEW IF NOT EXISTS student_scores AS SELECT "survey" AS kind, T.id_user, T.id_survey AS id, Q.id_question, MAX(R.score) AS score FROM (SELECT DISTINCT R.id_user, S.id_survey FROM survey_responses R INNER JOIN survey_quests Q ON R.id_question = Q.id_question INNER JOIN surveys S ON Q.id_survey = S.id_survey) T LEFT OUTER JOIN survey_quests Q ON T.id_survey = Q.id_survey LEFT OUTER JOIN survey_responses R ON R.id_user = T.id_user AND Q.id_question = R.id_question GROUP BY id_user, kind, id, id_question UNION SELECT "work" AS kind, G.id_user, G.id_work AS id, 0 AS id_question, G.grade AS score FROM works W RIGHT OUTER JOIN user_work_grades G ON G.id_work = W.id_work GROUP BY id_user, kind, id, id_question;
|
||||||
|
`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, id_category, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, id_category, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
direct.go
91
direct.go
|
|
@ -2,8 +2,10 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -280,6 +282,62 @@ func getCorrectionString(qid int64) (ret map[string]int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getResponsesStats(qid int64) map[string]interface{} {
|
||||||
|
q, err := getQuestion(int(qid))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
responses, err := q.GetResponses()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve responses:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{}
|
||||||
|
values := []uint{}
|
||||||
|
|
||||||
|
if q.Kind == "mcq" || q.Kind == "ucq" {
|
||||||
|
proposals, err := q.GetProposals()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal_idx := map[string]int{}
|
||||||
|
for _, p := range proposals {
|
||||||
|
proposal_idx[fmt.Sprintf("%d", p.Id)] = len(labels)
|
||||||
|
labels = append(labels, p.Label)
|
||||||
|
values = append(values, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range responses {
|
||||||
|
for _, v := range strings.Split(r.Answer, ",") {
|
||||||
|
values[proposal_idx[v]]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats := map[string]uint{}
|
||||||
|
|
||||||
|
for _, r := range responses {
|
||||||
|
stats[r.Answer]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range stats {
|
||||||
|
labels = append(labels, k)
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": []map[string][]uint{
|
||||||
|
map[string][]uint{
|
||||||
|
"values": values,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SurveyWSAdmin(c *gin.Context) {
|
func SurveyWSAdmin(c *gin.Context) {
|
||||||
u := c.MustGet("LoggedUser").(*User)
|
u := c.MustGet("LoggedUser").(*User)
|
||||||
survey := c.MustGet("survey").(*Survey)
|
survey := c.MustGet("survey").(*Survey)
|
||||||
|
|
@ -309,6 +367,7 @@ func SurveyWSAdmin(c *gin.Context) {
|
||||||
go func(c chan WSMessage, sid int) {
|
go func(c chan WSMessage, sid int) {
|
||||||
var v WSMessage
|
var v WSMessage
|
||||||
var err error
|
var err error
|
||||||
|
var surveyTimer *time.Timer
|
||||||
for {
|
for {
|
||||||
// Reset variable state
|
// Reset variable state
|
||||||
v.Corrected = false
|
v.Corrected = false
|
||||||
|
|
@ -326,19 +385,35 @@ func SurveyWSAdmin(c *gin.Context) {
|
||||||
if survey, err := getSurvey(sid); err != nil {
|
if survey, err := getSurvey(sid); err != nil {
|
||||||
log.Println("Unable to retrieve survey:", err)
|
log.Println("Unable to retrieve survey:", err)
|
||||||
} else {
|
} else {
|
||||||
|
// Skip any existing scheduled timer
|
||||||
|
if surveyTimer != nil {
|
||||||
|
if !surveyTimer.Stop() {
|
||||||
|
<-surveyTimer.C
|
||||||
|
}
|
||||||
|
surveyTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
survey.Direct = v.QuestionId
|
survey.Direct = v.QuestionId
|
||||||
if v.Timer > 0 {
|
if v.Timer > 0 {
|
||||||
survey.Corrected = false
|
survey.Corrected = false
|
||||||
survey.Update()
|
survey.Update()
|
||||||
|
|
||||||
go func(corrected bool) {
|
// Save corrected state for the callback
|
||||||
time.Sleep(time.Duration(OffsetQuestionTimer+v.Timer) * time.Millisecond)
|
corrected := v.Corrected
|
||||||
|
with_stats := v.Stats != nil
|
||||||
|
|
||||||
|
surveyTimer = time.AfterFunc(time.Duration(OffsetQuestionTimer+v.Timer)*time.Millisecond, func() {
|
||||||
|
surveyTimer = nil
|
||||||
if corrected {
|
if corrected {
|
||||||
survey.Corrected = v.Corrected
|
survey.Corrected = v.Corrected
|
||||||
survey.Update()
|
survey.Update()
|
||||||
|
|
||||||
survey.WSWriteAll(WSMessage{Action: "new_question", QuestionId: v.QuestionId, Corrected: true, Corrections: getCorrectionString(*v.QuestionId)})
|
var stats map[string]interface{}
|
||||||
|
if with_stats {
|
||||||
|
stats = getResponsesStats(*v.QuestionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
survey.WSWriteAll(WSMessage{Action: "new_question", QuestionId: v.QuestionId, Corrected: true, Stats: stats, Corrections: getCorrectionString(*v.QuestionId)})
|
||||||
} else {
|
} else {
|
||||||
var z int64 = 0
|
var z int64 = 0
|
||||||
survey.Direct = &z
|
survey.Direct = &z
|
||||||
|
|
@ -347,12 +422,20 @@ func SurveyWSAdmin(c *gin.Context) {
|
||||||
survey.WSWriteAll(WSMessage{Action: "pause"})
|
survey.WSWriteAll(WSMessage{Action: "pause"})
|
||||||
WSAdminWriteAll(WSMessage{Action: "pause", SurveyId: &survey.Id})
|
WSAdminWriteAll(WSMessage{Action: "pause", SurveyId: &survey.Id})
|
||||||
}
|
}
|
||||||
}(v.Corrected)
|
})
|
||||||
v.Corrected = false
|
v.Corrected = false
|
||||||
|
v.Stats = nil
|
||||||
} else {
|
} else {
|
||||||
survey.Corrected = v.Corrected
|
survey.Corrected = v.Corrected
|
||||||
if v.Corrected {
|
if v.Corrected {
|
||||||
v.Corrections = getCorrectionString(*v.QuestionId)
|
v.Corrections = getCorrectionString(*v.QuestionId)
|
||||||
|
if v.Stats != nil {
|
||||||
|
v.Stats = getResponsesStats(*v.QuestionId)
|
||||||
|
} else {
|
||||||
|
v.Stats = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.Stats = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = survey.Update()
|
_, err = survey.Update()
|
||||||
|
|
|
||||||
57
gitlab.go
57
gitlab.go
|
|
@ -170,11 +170,13 @@ type GitLabUser struct {
|
||||||
Username string
|
Username string
|
||||||
Name string
|
Name string
|
||||||
State string
|
State string
|
||||||
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitLabUserKey struct {
|
type GitLabUserKey struct {
|
||||||
ID int
|
ID int
|
||||||
Key string
|
Key string
|
||||||
|
UsageType string `json:"usage_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitLabRepository struct {
|
type GitLabRepository struct {
|
||||||
|
|
@ -269,7 +271,7 @@ func GitLab_getUsersRepositories(c context.Context, u *User) ([]*GitLabRepositor
|
||||||
return repositories, err
|
return repositories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GitLab_getUserId(c context.Context, u *User) (int, error) {
|
func GitLab_getUser(c context.Context, u *User) (*GitLabUser, error) {
|
||||||
client := gitlaboauth2Config.Client(c, gitlabToken())
|
client := gitlaboauth2Config.Client(c, gitlabToken())
|
||||||
|
|
||||||
val := url.Values{}
|
val := url.Values{}
|
||||||
|
|
@ -277,26 +279,35 @@ func GitLab_getUserId(c context.Context, u *User) (int, error) {
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", gitlabBaseURL+fmt.Sprintf("/api/v4/users?%s", val.Encode()), nil)
|
req, err := http.NewRequest("GET", gitlabBaseURL+fmt.Sprintf("/api/v4/users?%s", val.Encode()), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return 0, fmt.Errorf("Bad status code from the API")
|
return nil, fmt.Errorf("Bad status code from the API")
|
||||||
}
|
}
|
||||||
|
|
||||||
var users []*GitLabUser
|
var users []*GitLabUser
|
||||||
err = json.NewDecoder(resp.Body).Decode(&users)
|
err = json.NewDecoder(resp.Body).Decode(&users)
|
||||||
|
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return 0, fmt.Errorf("Login not found in GitLab")
|
return nil, fmt.Errorf("Login not found in GitLab")
|
||||||
}
|
}
|
||||||
|
|
||||||
return users[0].ID, nil
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GitLab_getUserId(c context.Context, u *User) (int, error) {
|
||||||
|
user, err := GitLab_getUser(c, u)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
|
func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
|
||||||
|
|
@ -334,3 +345,33 @@ func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
|
||||||
|
|
||||||
return b.Bytes(), nil
|
return b.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GitLab_getUserSSHKeys(c context.Context, u *User) ([]*GitLabUserKey, error) {
|
||||||
|
userid, err := GitLab_getUserId(c, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := gitlaboauth2Config.Client(c, gitlabToken())
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", gitlabBaseURL+fmt.Sprintf("/api/v4/users/%d/keys", userid), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
rep, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
log.Printf("%d %s", resp.StatusCode, rep)
|
||||||
|
return nil, fmt.Errorf("Bad status code from the API")
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []*GitLabUserKey
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&keys)
|
||||||
|
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
|
|
||||||
64
go.mod
64
go.mod
|
|
@ -1,28 +1,36 @@
|
||||||
module git.nemunai.re/atsebay.t
|
module git.nemunai.re/atsebay.t
|
||||||
|
|
||||||
go 1.18
|
go 1.21
|
||||||
|
|
||||||
|
toolchain go1.22.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4
|
github.com/ProtonMail/go-crypto v1.0.0
|
||||||
github.com/aws/aws-sdk-go v1.44.135
|
github.com/aws/aws-sdk-go v1.51.9
|
||||||
github.com/coreos/go-oidc/v3 v3.4.0
|
github.com/coreos/go-oidc/v3 v3.10.0
|
||||||
github.com/drone/drone-go v1.7.1
|
github.com/drone/drone-go v1.7.1
|
||||||
github.com/gin-gonic/gin v1.8.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
github.com/jcmturner/gokrb5/v8 v8.4.3
|
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||||
github.com/russross/blackfriday/v2 v2.1.0
|
github.com/russross/blackfriday/v2 v2.1.0
|
||||||
golang.org/x/oauth2 v0.2.0
|
golang.org/x/oauth2 v0.18.0
|
||||||
nhooyr.io/websocket v1.8.7
|
nhooyr.io/websocket v1.8.10
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cloudflare/circl v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/cloudflare/circl v1.3.3 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.0 // indirect
|
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.10.0 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/goccy/go-json v0.9.7 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||||
|
|
@ -31,18 +39,22 @@ require (
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.10.3 // indirect
|
github.com/klauspost/compress v1.10.3 // indirect
|
||||||
github.com/leodido/go-urn v1.2.1 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.7 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
golang.org/x/net v0.2.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/sys v0.2.0 // indirect
|
golang.org/x/crypto v0.21.0 // indirect
|
||||||
golang.org/x/text v0.4.0 // indirect
|
golang.org/x/net v0.22.0 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
google.golang.org/protobuf v1.28.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
121
gradation.go
Normal file
121
gradation.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/drone/drone-go/drone"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func declareAPIAdminGradationRoutes(router *gin.RouterGroup) {
|
||||||
|
router.GET("/gradation_repositories", func(c *gin.Context) {
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
result, err := client.RepoList()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve the repository list:", err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve the repository list."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
router.POST("/gradation_repositories/sync", func(c *gin.Context) {
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
result, err := client.RepoListSync()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve the repository list:", err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve the repository list."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestsWebhook struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
RepositoryId int `json:"repository_id"`
|
||||||
|
BuildNumber int `json:"build_number"`
|
||||||
|
UpTo float64 `json:"upto"`
|
||||||
|
Steps map[string]float64 `json:"steps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *TestsWebhook) fetchRepoTests(r *Repository) error {
|
||||||
|
tmp := strings.Split(r.TestsRef, "/")
|
||||||
|
if len(tmp) < 3 {
|
||||||
|
return fmt.Errorf("This repository tests reference is not filled properly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
work, err := getWork(r.IdWork)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Unable to retrieve the related work: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
result, err := client.Build(tmp[0], tmp[1], tw.BuildNumber)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Unable to find the referenced build (%d): %w", tw.BuildNumber, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Finished > 0 {
|
||||||
|
return fmt.Errorf("The test phase is not finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
var grade float64
|
||||||
|
for _, stage := range result.Stages {
|
||||||
|
for _, step := range stage.Steps {
|
||||||
|
if g, ok := tw.Steps[fmt.Sprintf("%d", step.Number)]; ok {
|
||||||
|
log.Printf("Step %q (%d) in status %q", step.Name, step.Number, step.Status)
|
||||||
|
// Give the point if it succeed
|
||||||
|
if step.Status == "success" {
|
||||||
|
grade += g
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if g, ok := tw.Steps[step.Name]; ok {
|
||||||
|
log.Printf("Step %q (%d) in status %q", step.Name, step.Number, step.Status)
|
||||||
|
// Give the point if it succeed
|
||||||
|
if step.Status == "success" {
|
||||||
|
grade += g
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := client.Logs(tmp[0], tmp[1], tw.BuildNumber, stage.Number, step.Number)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to retrieve build logs %s/%s/%d/%d/%d: %s", tmp[0], tmp[1], tw.BuildNumber, stage.Number, step.Number, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(logs) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
line := logs[len(logs)-1]
|
||||||
|
if strings.HasPrefix(logs[len(logs)-2].Message, "+ echo grade:") && strings.HasPrefix(line.Message, "grade:") {
|
||||||
|
g, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimPrefix(line.Message, "grade:")), 64)
|
||||||
|
if err == nil {
|
||||||
|
grade += g
|
||||||
|
} else {
|
||||||
|
log.Println("Unable to parse grade:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tw.UpTo != 0 {
|
||||||
|
grade = math.Trunc(grade*2000/tw.UpTo) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
work.AddGrade(WorkGrade{
|
||||||
|
IdUser: r.IdUser,
|
||||||
|
IdWork: work.Id,
|
||||||
|
Grade: grade,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
36
grades.go
36
grades.go
|
|
@ -1,8 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -65,28 +67,44 @@ func declareAPIAuthGradesRoutes(router *gin.RouterGroup) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllGrades() (scores map[int64]map[int64]*float64, err error) {
|
func gradeHandler(c *gin.Context) {
|
||||||
if rows, errr := DBQuery("SELECT id_user, id_survey, SUM(score)/COUNT(*) FROM student_scores GROUP BY id_user, id_survey"); errr != nil {
|
work := c.MustGet("work").(*Work)
|
||||||
|
|
||||||
|
if gid, err := strconv.Atoi(string(c.Param("gid"))); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad grade identifier."})
|
||||||
|
return
|
||||||
|
} else if grade, err := work.GetGrade(int64(gid)); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Grade not found."})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.Set("grade", grade)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllGrades() (scores map[int64]map[string]*float64, err error) {
|
||||||
|
if rows, errr := DBQuery("SELECT id_user, kind, id, SUM(score)/COUNT(*) FROM student_scores GROUP BY id_user, kind, id"); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
scores = map[int64]map[int64]*float64{}
|
scores = map[int64]map[string]*float64{}
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id_user int64
|
var id_user int64
|
||||||
var id_survey int64
|
var kind string
|
||||||
|
var id int64
|
||||||
var score *float64
|
var score *float64
|
||||||
|
|
||||||
if err = rows.Scan(&id_user, &id_survey, &score); err != nil {
|
if err = rows.Scan(&id_user, &kind, &id, &score); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if scores[id_user] == nil {
|
if scores[id_user] == nil {
|
||||||
scores[id_user] = map[int64]*float64{}
|
scores[id_user] = map[string]*float64{}
|
||||||
}
|
}
|
||||||
|
|
||||||
scores[id_user][id_survey] = score
|
scores[id_user][fmt.Sprintf("%c.%d", kind[0], id)] = score
|
||||||
}
|
}
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -97,7 +115,7 @@ func GetAllGrades() (scores map[int64]map[int64]*float64, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Survey) GetGrades() (scores map[int64]*float64, err error) {
|
func (s Survey) GetGrades() (scores map[int64]*float64, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_question, SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? GROUP BY id_question", s.Id); errr != nil {
|
if rows, errr := DBQuery("SELECT id_question, SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id=? GROUP BY id_question", s.Id); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
@ -122,7 +140,7 @@ func (s Survey) GetGrades() (scores map[int64]*float64, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Survey) GetUserGrades(u *User) (scores map[int64]*float64, err error) {
|
func (s Survey) GetUserGrades(u *User) (scores map[int64]*float64, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_question, MAX(score) FROM student_scores WHERE id_survey=? AND id_user = ? GROUP BY id_question", s.Id, u.Id); errr != nil {
|
if rows, errr := DBQuery("SELECT id_question, MAX(score) FROM student_scores WHERE kind = 'survey' AND id=? AND id_user = ? GROUP BY id_question", s.Id, u.Id); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
|
||||||
38
keys.go
38
keys.go
|
|
@ -50,6 +50,44 @@ func declareAPIKeysRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
c.Data(http.StatusOK, "application/pgp-keys", ret)
|
c.Data(http.StatusOK, "application/pgp-keys", ret)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usersRoutes.GET("/allowed_signers", func(c *gin.Context) {
|
||||||
|
var u *User
|
||||||
|
if user, ok := c.Get("user"); ok {
|
||||||
|
u = user.(*User)
|
||||||
|
} else {
|
||||||
|
u = c.MustGet("LoggedUser").(*User)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := GitLab_getUser(c.Request.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to GitLab_getUser:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve your GitLab user. Please try again in a few moment."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := GitLab_getUserSSHKeys(c.Request.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to GitLab_getUserSSHKeys:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve your keys from GitLab. Please try again in a few moment."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret string
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.UsageType != "auth_and_signing" && k.UsageType != "signing" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Email) > 0 {
|
||||||
|
ret += fmt.Sprintf("%s %s\n", user.Email, k.Key)
|
||||||
|
} else {
|
||||||
|
ret += fmt.Sprintf("*@epita.fr %s\n", k.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "text/plain", []byte(ret))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareAPIAuthKeysRoutes(router *gin.RouterGroup) {
|
func declareAPIAuthKeysRoutes(router *gin.RouterGroup) {
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -58,6 +58,7 @@ func main() {
|
||||||
var bind = flag.String("bind", ":8081", "Bind port/socket")
|
var bind = flag.String("bind", ":8081", "Bind port/socket")
|
||||||
var dsn = flag.String("dsn", DSNGenerator(), "DSN to connect to the MySQL server")
|
var dsn = flag.String("dsn", DSNGenerator(), "DSN to connect to the MySQL server")
|
||||||
var dummyauth = flag.Bool("dummy-auth", false, "If set, allow any authentication credentials")
|
var dummyauth = flag.Bool("dummy-auth", false, "If set, allow any authentication credentials")
|
||||||
|
flag.StringVar(&mainBanner, "banner-message", mainBanner, "Display a message to connected user, at the top of the screen")
|
||||||
flag.StringVar(&DevProxy, "dev", DevProxy, "Proxify traffic to this host for static assets")
|
flag.StringVar(&DevProxy, "dev", DevProxy, "Proxify traffic to this host for static assets")
|
||||||
flag.StringVar(&baseURL, "baseurl", baseURL, "URL prepended to each URL")
|
flag.StringVar(&baseURL, "baseurl", baseURL, "URL prepended to each URL")
|
||||||
flag.UintVar(¤tPromo, "current-promo", currentPromo, "Year of the current promotion")
|
flag.UintVar(¤tPromo, "current-promo", currentPromo, "Year of the current promotion")
|
||||||
|
|
|
||||||
56
questions.go
56
questions.go
|
|
@ -39,7 +39,7 @@ func declareAPIAuthQuestionsRoutes(router *gin.RouterGroup) {
|
||||||
c.JSON(http.StatusOK, questions)
|
c.JSON(http.StatusOK, questions)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!s.Shown || s.Direct != nil) && !u.IsAdmin {
|
if s.Direct != nil && !u.IsAdmin {
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -60,26 +60,10 @@ func declareAPIAuthQuestionsRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
questionsRoutes := router.Group("/questions/:qid")
|
questionsRoutes := router.Group("/questions/:qid")
|
||||||
questionsRoutes.Use(questionHandler)
|
questionsRoutes.Use(questionHandler)
|
||||||
|
questionsRoutes.Use(questionUserAccessHandler)
|
||||||
|
|
||||||
questionsRoutes.GET("", func(c *gin.Context) {
|
questionsRoutes.GET("", func(c *gin.Context) {
|
||||||
q := c.MustGet("question").(*Question)
|
c.JSON(http.StatusOK, c.MustGet("question").(*Question))
|
||||||
u := c.MustGet("LoggedUser").(*User)
|
|
||||||
|
|
||||||
if !u.IsAdmin {
|
|
||||||
s, err := getSurvey(int(q.IdSurvey))
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Unable to getSurvey:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during survey retrieval. Please try again later."})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.Shown || (s.Direct != nil && *s.Direct != q.Id) {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not authorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, q)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
declareAPIAuthProposalsRoutes(questionsRoutes)
|
declareAPIAuthProposalsRoutes(questionsRoutes)
|
||||||
|
|
@ -114,6 +98,7 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
questionsRoutes := router.Group("/questions/:qid")
|
questionsRoutes := router.Group("/questions/:qid")
|
||||||
questionsRoutes.Use(questionHandler)
|
questionsRoutes.Use(questionHandler)
|
||||||
|
questionsRoutes.Use(questionUserAccessHandler)
|
||||||
|
|
||||||
questionsRoutes.PUT("", func(c *gin.Context) {
|
questionsRoutes.PUT("", func(c *gin.Context) {
|
||||||
current := c.MustGet("question").(*Question)
|
current := c.MustGet("question").(*Question)
|
||||||
|
|
@ -154,6 +139,7 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
|
||||||
func declareAPIAdminUserQuestionsRoutes(router *gin.RouterGroup) {
|
func declareAPIAdminUserQuestionsRoutes(router *gin.RouterGroup) {
|
||||||
questionsRoutes := router.Group("/questions/:qid")
|
questionsRoutes := router.Group("/questions/:qid")
|
||||||
questionsRoutes.Use(questionHandler)
|
questionsRoutes.Use(questionHandler)
|
||||||
|
questionsRoutes.Use(questionUserAccessHandler)
|
||||||
|
|
||||||
questionsRoutes.GET("", func(c *gin.Context) {
|
questionsRoutes.GET("", func(c *gin.Context) {
|
||||||
question := c.MustGet("question").(*Question)
|
question := c.MustGet("question").(*Question)
|
||||||
|
|
@ -203,6 +189,38 @@ func questionHandler(c *gin.Context) {
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func questionUserAccessHandler(c *gin.Context) {
|
||||||
|
var survey *Survey
|
||||||
|
if s, ok := c.Get("survey"); ok {
|
||||||
|
survey = s.(*Survey)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := c.MustGet("LoggedUser").(*User)
|
||||||
|
question := c.MustGet("question").(*Question)
|
||||||
|
|
||||||
|
if survey == nil {
|
||||||
|
s, err := getSurvey(int(question.IdSurvey))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to getSurvey:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during survey retrieval. Please try again later."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
survey = s
|
||||||
|
}
|
||||||
|
|
||||||
|
if !u.IsAdmin && (!survey.checkUserAccessToSurvey(u) || (survey.Direct != nil && *survey.Direct != question.Id)) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not authorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !u.IsAdmin && survey.StartAvailability.After(time.Now()) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible yet"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
IdSurvey int64 `json:"id_survey"`
|
IdSurvey int64 `json:"id_survey"`
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"packageRules": [
|
"extends": [
|
||||||
{
|
"local>iac/renovate-config",
|
||||||
"matchPackageNames": ["alpine", "golang.org/x/oauth2", "github.com/aws/aws-sdk-go"],
|
"local>iac/renovate-config//automerge-common"
|
||||||
"automerge": true,
|
|
||||||
"automergeType": "branch"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
repositories.go
334
repositories.go
|
|
@ -7,24 +7,30 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/s3"
|
||||||
"github.com/drone/drone-go/drone"
|
"github.com/drone/drone-go/drone"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
droneToken = ""
|
droneToken = ""
|
||||||
droneConfig *http.Client
|
droneConfig *http.Client
|
||||||
droneEndpoint string
|
droneEndpoint string
|
||||||
|
testsCallbackToken string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
flag.StringVar(&droneToken, "drone-token", droneToken, "Token for Drone Oauth")
|
flag.StringVar(&droneToken, "drone-token", droneToken, "Token for Drone Oauth")
|
||||||
flag.StringVar(&droneEndpoint, "drone-endpoint", droneEndpoint, "Drone Endpoint")
|
flag.StringVar(&droneEndpoint, "drone-endpoint", droneEndpoint, "Drone Endpoint")
|
||||||
|
flag.StringVar(&testsCallbackToken, "tests-callback-token", testsCallbackToken, "Token of the callback token")
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeDroneOauth() {
|
func initializeDroneOauth() {
|
||||||
|
|
@ -39,6 +45,11 @@ func initializeDroneOauth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RepositoryAdminPull struct {
|
||||||
|
Tag *string `json:"tag"`
|
||||||
|
OptionalSignature bool `json:"sig_optional"`
|
||||||
|
}
|
||||||
|
|
||||||
func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
router.GET("/repositories", func(c *gin.Context) {
|
router.GET("/repositories", func(c *gin.Context) {
|
||||||
var u *User
|
var u *User
|
||||||
|
|
@ -62,7 +73,6 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
if r.IdWork == work.(*Work).Id {
|
if r.IdWork == work.(*Work).Id {
|
||||||
// Is the URL used elsewhere?
|
// Is the URL used elsewhere?
|
||||||
repos, _ := getRepositoriesByURI(r.URI)
|
repos, _ := getRepositoriesByURI(r.URI)
|
||||||
log.Println(repos)
|
|
||||||
if len(repos) > 1 {
|
if len(repos) > 1 {
|
||||||
r.AlreadyUsed = true
|
r.AlreadyUsed = true
|
||||||
}
|
}
|
||||||
|
|
@ -90,12 +100,39 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uri, err := url.Parse(repository.URI)
|
||||||
|
if err != nil {
|
||||||
|
tmp := strings.Split(repository.URI, ":")
|
||||||
|
if len(tmp) == 2 {
|
||||||
|
uri, err = url.Parse(fmt.Sprintf("ssh://%s/%s", tmp[0], tmp[1]))
|
||||||
|
} else if len(tmp) == 3 {
|
||||||
|
uri, err = url.Parse(fmt.Sprintf("%s://%s/%s", tmp[0], tmp[1], tmp[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("invalid repository URL: %s", err.Error())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if uri.Scheme != "ssh" && uri.Scheme != "git+ssh" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Unrecognized URL scheme. You need to provide a SSH repository URL."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(uri.Host, "epita.fr") {
|
||||||
|
if !strings.HasPrefix(uri.Path, fmt.Sprintf("/%s/", u.Login)) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "repository URL forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var w *Work
|
var w *Work
|
||||||
if work, ok := c.Get("work"); ok {
|
if work, ok := c.Get("work"); ok {
|
||||||
w = work.(*Work)
|
w = work.(*Work)
|
||||||
} else if repository.IdWork > 0 {
|
} else if repository.IdWork > 0 {
|
||||||
var err error
|
var err error
|
||||||
w, err = getWork(int(repository.IdWork))
|
w, err = getWork(repository.IdWork)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Unable to find the given work identifier."})
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Unable to find the given work identifier."})
|
||||||
return
|
return
|
||||||
|
|
@ -123,7 +160,6 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
// Is the URL used elsewhere?
|
// Is the URL used elsewhere?
|
||||||
repos, _ := getRepositoriesByURI(repo.URI)
|
repos, _ := getRepositoriesByURI(repo.URI)
|
||||||
log.Println(repos)
|
|
||||||
if len(repos) > 1 {
|
if len(repos) > 1 {
|
||||||
repo.AlreadyUsed = true
|
repo.AlreadyUsed = true
|
||||||
}
|
}
|
||||||
|
|
@ -156,8 +192,9 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
repositoriesRoutes.DELETE("", func(c *gin.Context) {
|
repositoriesRoutes.DELETE("", func(c *gin.Context) {
|
||||||
|
loggeduser := c.MustGet("LoggedUser").(*User)
|
||||||
repository := c.MustGet("repository").(*Repository)
|
repository := c.MustGet("repository").(*Repository)
|
||||||
work, err := getWork(int(repository.IdWork))
|
work, err := getWork(repository.IdWork)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||||
return
|
return
|
||||||
|
|
@ -166,7 +203,7 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
if !work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Before(now) {
|
if !loggeduser.IsAdmin && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Before(now)) {
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -189,11 +226,10 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
u = loggeduser
|
u = loggeduser
|
||||||
}
|
}
|
||||||
repo := c.MustGet("repository").(*Repository)
|
repo := c.MustGet("repository").(*Repository)
|
||||||
work, err := getWork(int(repo.IdWork))
|
work, err := getWork(repo.IdWork)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
@ -203,12 +239,12 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var tag *string
|
var rap RepositoryAdminPull
|
||||||
if loggeduser.IsAdmin {
|
if loggeduser.IsAdmin {
|
||||||
c.ShouldBindJSON(&tag)
|
c.ShouldBindJSON(&rap)
|
||||||
}
|
}
|
||||||
|
|
||||||
TriggerTagUpdate(c, work, repo, u, tag)
|
TriggerTagUpdate(c, work, repo, u, rap.Tag, rap.OptionalSignature)
|
||||||
})
|
})
|
||||||
|
|
||||||
repositoriesRoutes.GET("/state", func(c *gin.Context) {
|
repositoriesRoutes.GET("/state", func(c *gin.Context) {
|
||||||
|
|
@ -265,6 +301,65 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
repositoriesRoutes.POST("/gradation", func(c *gin.Context) {
|
||||||
|
loggeduser := c.MustGet("LoggedUser").(*User)
|
||||||
|
if !loggeduser.IsAdmin {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Permission denied."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var u *User
|
||||||
|
if user, ok := c.Get("user"); ok {
|
||||||
|
u = user.(*User)
|
||||||
|
} else {
|
||||||
|
u = loggeduser
|
||||||
|
}
|
||||||
|
repo := c.MustGet("repository").(*Repository)
|
||||||
|
work, err := getWork(repo.IdWork)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TriggerTests(c, work, repo, u)
|
||||||
|
})
|
||||||
|
|
||||||
|
repositoriesRoutes.GET("/gradation_status", func(c *gin.Context) {
|
||||||
|
loggeduser := c.MustGet("LoggedUser").(*User)
|
||||||
|
if !loggeduser.IsAdmin {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Permission denied."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := c.MustGet("repository").(*Repository)
|
||||||
|
|
||||||
|
slug := strings.Split(repo.TestsRef, "/")
|
||||||
|
if len(slug) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildn, err := strconv.ParseInt(slug[2], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
build, err := client.Build(slug[0], slug[1], int(buildn))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to communicate with Drone:", err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communicate with Drone"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, build)
|
||||||
|
})
|
||||||
|
|
||||||
|
repositoriesRoutes.GET("/traces", func(c *gin.Context) {
|
||||||
|
repo := c.MustGet("repository").(*Repository)
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, fmt.Sprintf("%s/%s", droneEndpoint, repo.TestsRef))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitLabWebhook struct {
|
type GitLabWebhook struct {
|
||||||
|
|
@ -309,7 +404,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
work, err := getWork(int(repo.IdWork))
|
work, err := getWork(repo.IdWork)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Unable to getWork:", err.Error())
|
log.Println("Unable to getWork:", err.Error())
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||||
|
|
@ -327,7 +422,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
||||||
|
|
||||||
tmp := strings.SplitN(hook.Ref, "/", 3)
|
tmp := strings.SplitN(hook.Ref, "/", 3)
|
||||||
if len(tmp) != 3 {
|
if len(tmp) != 3 {
|
||||||
TriggerTagUpdate(c, work, repo, user, nil)
|
TriggerTagUpdate(c, work, repo, user, nil, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,7 +430,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
||||||
// Allow to use a secret for another tag
|
// Allow to use a secret for another tag
|
||||||
if len(repos) > 1 {
|
if len(repos) > 1 {
|
||||||
for _, r := range repos {
|
for _, r := range repos {
|
||||||
w, err := getWork(int(r.IdWork))
|
w, err := getWork(r.IdWork)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Unable to getWork:", err.Error())
|
log.Println("Unable to getWork:", err.Error())
|
||||||
continue
|
continue
|
||||||
|
|
@ -355,7 +450,37 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TriggerTagUpdate(c, work, repo, user, &tmp[2])
|
TriggerTagUpdate(c, work, repo, user, &tmp[2], false)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/callbacks/tests.json", func(c *gin.Context) {
|
||||||
|
// Check auth token
|
||||||
|
if c.GetHeader("X-Authorization") != testsCallbackToken {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Authorization token is invalid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
hook := TestsWebhook{}
|
||||||
|
if err := c.ShouldBindJSON(&hook); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve corresponding repository
|
||||||
|
repo, err := getRepository(hook.RepositoryId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to getRepository(%d): %s", hook.RepositoryId, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve repository."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hook.fetchRepoTests(repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to fetchRepoTests(%d): %s", hook.RepositoryId, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to fetch tests results."})
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,11 +512,11 @@ func repositoryHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag *string) {
|
func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag *string, sig_optional bool) {
|
||||||
loggeduser := c.MustGet("LoggedUser").(*User)
|
loggeduser := c.MustGet("LoggedUser").(*User)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
if !loggeduser.IsAdmin && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Add(time.Hour).Before(now)) {
|
if (loggeduser == nil || !loggeduser.IsAdmin) && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Add(time.Hour).Before(now)) {
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -411,21 +536,27 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := drone.NewClient(droneEndpoint, droneConfig)
|
env := map[string]string{
|
||||||
result, err := client.BuildCreate("srs", "atsebay.t-worker", "", "master", map[string]string{
|
|
||||||
"REPO_URL": repo.URI,
|
"REPO_URL": repo.URI,
|
||||||
"REPO_TAG": repo_tag,
|
"REPO_TAG": repo_tag,
|
||||||
"LOGIN": login,
|
"LOGIN": login,
|
||||||
"GROUPS": groups,
|
"GROUPS": groups,
|
||||||
"DEST": fmt.Sprintf("%d", work.Id),
|
"DEST": fmt.Sprintf("%d", work.Id),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if sig_optional {
|
||||||
|
env["TAG_SIG_OPTIONAL"] = "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
result, err := client.BuildCreate("teach", "atsebay.t-worker", "", "master", env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Unable to communicate with Drone:", err.Error())
|
log.Println("Unable to communicate with Drone:", err.Error())
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the extraction service."})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the extraction service."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.DroneRef = fmt.Sprintf("%s/%s/%d", "srs", "atsebay.t-worker", result.Number)
|
repo.DroneRef = fmt.Sprintf("%s/%s/%d", "teach", "atsebay.t-worker", result.Number)
|
||||||
repo.LastCheck = &now
|
repo.LastCheck = &now
|
||||||
repo.Update()
|
repo.Update()
|
||||||
|
|
||||||
|
|
@ -433,6 +564,116 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
|
||||||
c.JSON(http.StatusOK, repo)
|
c.JSON(http.StatusOK, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TriggerTests(c *gin.Context, work *Work, repo *Repository, u *User) {
|
||||||
|
if work.GradationRepo == nil || len(*work.GradationRepo) == 0 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No tests defined for this work."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := strings.SplitN(*work.GradationRepo, "/", 2)
|
||||||
|
if len(slug) != 2 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Graduation repository is invalid."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
login := u.Login
|
||||||
|
groups := u.Groups
|
||||||
|
if u.Id != repo.IdUser {
|
||||||
|
user, _ := getUser(int(repo.IdUser))
|
||||||
|
if user != nil {
|
||||||
|
login = user.Login
|
||||||
|
groups = user.Groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := "master"
|
||||||
|
if len(work.Tag) > 0 {
|
||||||
|
branch = work.Tag
|
||||||
|
}
|
||||||
|
if branch[len(branch)-1] == '-' {
|
||||||
|
branch += "grades"
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := s3NewSession()
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something goes wrong."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := s3.New(s).GetObjectRequest(&s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3_bucket),
|
||||||
|
Key: aws.String(filepath.Join(fmt.Sprintf("%d", work.Id), fmt.Sprintf("rendu-%s.tar.xz", login))),
|
||||||
|
})
|
||||||
|
|
||||||
|
url, err := req.Presign(SharingTime * 20)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to create presign URL:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something goes wrong when creating the presigned URL."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
"SUBMISSION_URL": repo.URI,
|
||||||
|
"REPO_ID": fmt.Sprintf("%d", repo.Id),
|
||||||
|
"META_URL": url,
|
||||||
|
"LOGIN": login,
|
||||||
|
"GROUPS": groups,
|
||||||
|
"DEST": fmt.Sprintf("%d", work.Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
result, err := client.BuildCreate(slug[0], slug[1], "", branch, env)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to communicate with Drone:", err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the gradation service."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
repo.TestsRef = fmt.Sprintf("%s/%d", *work.GradationRepo, result.Number)
|
||||||
|
repo.LastTests = &now
|
||||||
|
repo.Update()
|
||||||
|
|
||||||
|
repo.Secret = []byte{}
|
||||||
|
c.JSON(http.StatusOK, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Work) stopTests() error {
|
||||||
|
repos, err := w.GetRepositories()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||||
|
for _, repo := range repos {
|
||||||
|
slug := strings.Split(repo.TestsRef, "/")
|
||||||
|
if len(slug) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buildn, err := strconv.ParseInt(slug[2], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
build, err := client.Build(slug[0], slug[1], int(buildn))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to communicate with Drone:", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if build.Status == "pending" {
|
||||||
|
err := client.BuildCancel(slug[0], slug[1], int(buildn))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to cancel the build:", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
IdUser int64 `json:"id_user"`
|
IdUser int64 `json:"id_user"`
|
||||||
|
|
@ -441,18 +682,41 @@ type Repository struct {
|
||||||
Secret []byte `json:"secret,omitempty"`
|
Secret []byte `json:"secret,omitempty"`
|
||||||
LastCheck *time.Time `json:"last_check"`
|
LastCheck *time.Time `json:"last_check"`
|
||||||
DroneRef string `json:"drone_ref,omitempty"`
|
DroneRef string `json:"drone_ref,omitempty"`
|
||||||
|
LastTests *time.Time `json:"last_tests"`
|
||||||
|
TestsRef string `json:"tests_ref,omitempty"`
|
||||||
AlreadyUsed bool `json:"already_used,omitempty"`
|
AlreadyUsed bool `json:"already_used,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) GetRepositories() (repositories []*Repository, err error) {
|
func (u *User) GetRepositories() (repositories []*Repository, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_user=?", u.Id); errr != nil {
|
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_user=?", u.Id); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var repo Repository
|
var repo Repository
|
||||||
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef); err != nil {
|
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repositories = append(repositories, &repo)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Work) GetRepositories() (repositories []*Repository, err error) {
|
||||||
|
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_work=?", w.Id); errr != nil {
|
||||||
|
return nil, errr
|
||||||
|
} else {
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var repo Repository
|
||||||
|
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repositories = append(repositories, &repo)
|
repositories = append(repositories, &repo)
|
||||||
|
|
@ -466,14 +730,14 @@ func (u *User) GetRepositories() (repositories []*Repository, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
|
func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE uri=?", uri); errr != nil {
|
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE uri=?", uri); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var repo Repository
|
var repo Repository
|
||||||
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef); err != nil {
|
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repositories = append(repositories, &repo)
|
repositories = append(repositories, &repo)
|
||||||
|
|
@ -488,13 +752,19 @@ func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
|
||||||
|
|
||||||
func getRepository(id int) (r *Repository, err error) {
|
func getRepository(id int) (r *Repository, err error) {
|
||||||
r = new(Repository)
|
r = new(Repository)
|
||||||
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_repository=?", id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef)
|
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_repository=?", id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) getRepository(id int) (r *Repository, err error) {
|
func (u *User) getRepository(id int) (r *Repository, err error) {
|
||||||
r = new(Repository)
|
r = new(Repository)
|
||||||
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_repository=? AND id_user=?", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef)
|
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_repository=? AND id_user=?", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) getRepositoryByWork(id int64) (r *Repository, err error) {
|
||||||
|
r = new(Repository)
|
||||||
|
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_work=? AND id_user=? ORDER BY last_tests DESC LIMIT 1", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,17 +775,17 @@ func (u *User) NewRepository(w *Work, uri string) (*Repository, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if res, err := DBExec("INSERT INTO user_work_repositories (id_user, id_work, uri, secret, droneref) VALUES (?, ?, ?, ?, ?)", u.Id, w.Id, uri, secret, ""); err != nil {
|
if res, err := DBExec("INSERT INTO user_work_repositories (id_user, id_work, uri, secret, droneref, testsref) VALUES (?, ?, ?, ?, ?, '')", u.Id, w.Id, uri, secret, ""); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if rid, err := res.LastInsertId(); err != nil {
|
} else if rid, err := res.LastInsertId(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
return &Repository{rid, u.Id, w.Id, uri, secret, nil, "", false}, nil
|
return &Repository{rid, u.Id, w.Id, uri, secret, nil, "", nil, "", false}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Update() (*Repository, error) {
|
func (r *Repository) Update() (*Repository, error) {
|
||||||
if _, err := DBExec("UPDATE user_work_repositories SET id_user = ?, id_work = ?, uri = ?, secret = ?, last_check = ?, droneref = ? WHERE id_repository = ?", r.IdUser, r.IdWork, r.URI, r.Secret, r.LastCheck, r.DroneRef, r.Id); err != nil {
|
if _, err := DBExec("UPDATE user_work_repositories SET id_user = ?, id_work = ?, uri = ?, secret = ?, last_check = ?, droneref = ?, last_tests = ?, testsref = ? WHERE id_repository = ?", r.IdUser, r.IdWork, r.URI, r.Secret, r.LastCheck, r.DroneRef, r.LastTests, r.TestsRef, r.Id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
return r, err
|
return r, err
|
||||||
|
|
|
||||||
187
shares.go
Normal file
187
shares.go
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func declareAPISharesRoutes(router *gin.RouterGroup) {
|
||||||
|
surveysRoutes := router.Group("/s/surveys/:sid")
|
||||||
|
surveysRoutes.Use(surveyHandler)
|
||||||
|
surveysRoutes.Use(sharesAccessHandler)
|
||||||
|
|
||||||
|
surveysRoutes.GET("/", func(c *gin.Context) {
|
||||||
|
share := c.MustGet("survey_share").(*SurveyShared)
|
||||||
|
share.Count += 1
|
||||||
|
share.Update()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, c.MustGet("survey").(*Survey))
|
||||||
|
})
|
||||||
|
|
||||||
|
surveysRoutes.GET("/questions", func(c *gin.Context) {
|
||||||
|
s := c.MustGet("survey").(*Survey)
|
||||||
|
|
||||||
|
if questions, err := s.GetQuestions(); err != nil {
|
||||||
|
log.Println("Unable to getQuestions:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve questions. Please try again later."})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, questions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
questionsRoutes := surveysRoutes.Group("/questions/:qid")
|
||||||
|
questionsRoutes.Use(questionHandler)
|
||||||
|
|
||||||
|
questionsRoutes.GET("/", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, c.MustGet("question").(*Question))
|
||||||
|
})
|
||||||
|
|
||||||
|
questionsRoutes.GET("/proposals", func(c *gin.Context) {
|
||||||
|
q := c.MustGet("question").(*Question)
|
||||||
|
|
||||||
|
proposals, err := q.GetProposals()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to GetProposals(qid=%d): %s", q.Id, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during proposals retrieving"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, proposals)
|
||||||
|
})
|
||||||
|
|
||||||
|
questionsRoutes.GET("/responses", func(c *gin.Context) {
|
||||||
|
q := c.MustGet("question").(*Question)
|
||||||
|
|
||||||
|
res, err := q.GetResponses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to GetResponses(qid=%d): %s", q.Id, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during responses retrieval."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharesAccessHandler(c *gin.Context) {
|
||||||
|
s := c.MustGet("survey").(*Survey)
|
||||||
|
secret := c.Query("secret")
|
||||||
|
|
||||||
|
shares, err := s.getShares()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to getShares(sid=%d): %s", s.Id, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something went wrong when authenticating the query."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, share := range shares {
|
||||||
|
if share.Authenticate(secret) {
|
||||||
|
c.Set("survey_share", share)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SurveyShared struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
IdSurvey int64 `json:"id_survey"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Survey) Share() (*SurveyShared, error) {
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(secret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, err := DBExec("INSERT INTO survey_shared (id_survey, secret) VALUES (?, ?)", s.Id, secret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if sid, err := res.LastInsertId(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return &SurveyShared{sid, s.Id, 0, secret}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SurveyShared) getMAC() []byte {
|
||||||
|
mac := hmac.New(sha512.New, sh.secret)
|
||||||
|
mac.Write([]byte(fmt.Sprintf("%d", sh.IdSurvey)))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SurveyShared) GetURL() (*url.URL, error) {
|
||||||
|
u, err := url.Parse(oidcRedirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Path = filepath.Join(baseURL, "results")
|
||||||
|
u.RawQuery = url.Values{
|
||||||
|
"secret": []string{base64.RawURLEncoding.EncodeToString(sh.getMAC())},
|
||||||
|
"survey": []string{fmt.Sprintf("%d", sh.IdSurvey)},
|
||||||
|
}.Encode()
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Survey) getShares() (shares []*SurveyShared, err error) {
|
||||||
|
if rows, errr := DBQuery("SELECT id_share, id_survey, secret, count FROM survey_shared"); errr != nil {
|
||||||
|
return nil, errr
|
||||||
|
} else {
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var sh SurveyShared
|
||||||
|
if err = rows.Scan(&sh.Id, &sh.IdSurvey, &sh.secret, &sh.Count); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shares = append(shares, &sh)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SurveyShared) Authenticate(secret string) bool {
|
||||||
|
messageMAC, err := base64.RawURLEncoding.DecodeString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return hmac.Equal(messageMAC, sh.getMAC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SurveyShared) Update() (*SurveyShared, error) {
|
||||||
|
if _, err := DBExec("UPDATE survey_shared SET id_survey = ?, secret = ?, count = ? WHERE id_share = ?", sh.IdSurvey, sh.secret, sh.Count, sh.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return sh, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh SurveyShared) Delete() (int64, error) {
|
||||||
|
if res, err := DBExec("DELETE FROM survey_shared WHERE id_share = ?", sh.Id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else if nb, err := res.RowsAffected(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else {
|
||||||
|
return nb, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,15 +48,18 @@ func serveOrReverse(forced_url string) func(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareStaticRoutes(router *gin.Engine) {
|
func declareStaticRoutes(router *gin.Engine) {
|
||||||
router.GET("/@fs/*_", serveOrReverse(""))
|
|
||||||
router.GET("/", serveOrReverse(""))
|
router.GET("/", serveOrReverse(""))
|
||||||
router.GET("/_app/*_", serveOrReverse(""))
|
router.GET("/_app/*_", serveOrReverse(""))
|
||||||
router.GET("/auth/", serveOrReverse("/"))
|
router.GET("/auth/", serveOrReverse("/"))
|
||||||
router.GET("/bug-bounty", serveOrReverse("/"))
|
router.GET("/bug-bounty", serveOrReverse("/"))
|
||||||
|
router.GET("/categories", serveOrReverse("/"))
|
||||||
|
router.GET("/categories/*_", serveOrReverse("/"))
|
||||||
router.GET("/donnees-personnelles", serveOrReverse("/"))
|
router.GET("/donnees-personnelles", serveOrReverse("/"))
|
||||||
router.GET("/grades", serveOrReverse("/"))
|
router.GET("/grades", serveOrReverse("/"))
|
||||||
|
router.GET("/grades/*_", serveOrReverse("/"))
|
||||||
router.GET("/help", serveOrReverse("/"))
|
router.GET("/help", serveOrReverse("/"))
|
||||||
router.GET("/keys", serveOrReverse("/"))
|
router.GET("/keys", serveOrReverse("/"))
|
||||||
|
router.GET("/results", serveOrReverse("/"))
|
||||||
router.GET("/surveys", serveOrReverse("/"))
|
router.GET("/surveys", serveOrReverse("/"))
|
||||||
router.GET("/surveys/*_", serveOrReverse("/"))
|
router.GET("/surveys/*_", serveOrReverse("/"))
|
||||||
router.GET("/users", serveOrReverse("/"))
|
router.GET("/users", serveOrReverse("/"))
|
||||||
|
|
@ -71,7 +74,7 @@ func declareStaticRoutes(router *gin.Engine) {
|
||||||
router.GET("/.svelte-kit/*_", serveOrReverse(""))
|
router.GET("/.svelte-kit/*_", serveOrReverse(""))
|
||||||
router.GET("/node_modules/*_", serveOrReverse(""))
|
router.GET("/node_modules/*_", serveOrReverse(""))
|
||||||
router.GET("/@vite/*_", serveOrReverse(""))
|
router.GET("/@vite/*_", serveOrReverse(""))
|
||||||
router.GET("/__vite_ping", serveOrReverse(""))
|
router.GET("/@fs/*_", serveOrReverse(""))
|
||||||
router.GET("/src/*_", serveOrReverse(""))
|
router.GET("/src/*_", serveOrReverse(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SharingTime = 15 * time.Minute
|
const SharingTime = 10 * time.Minute
|
||||||
|
|
||||||
var (
|
var (
|
||||||
s3_endpoint string
|
s3_endpoint string
|
||||||
|
|
|
||||||
104
surveys.go
104
surveys.go
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -62,19 +63,14 @@ func declareAPISurveysRoutes(router *gin.RouterGroup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s := c.MustGet("survey").(*Survey)
|
c.JSON(http.StatusOK, c.MustGet("survey").(*Survey))
|
||||||
|
|
||||||
if (s.Promo == u.Promo && (s.Group == "" || strings.Contains(u.Groups, ","+s.Group+",") && s.Shown)) || u.IsAdmin {
|
|
||||||
c.JSON(http.StatusOK, s)
|
|
||||||
} else {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
||||||
surveysRoutes := router.Group("/surveys/:sid")
|
surveysRoutes := router.Group("/surveys/:sid")
|
||||||
surveysRoutes.Use(surveyHandler)
|
surveysRoutes.Use(surveyHandler)
|
||||||
|
surveysRoutes.Use(surveyUserAccessHandler)
|
||||||
|
|
||||||
surveysRoutes.GET("/score", func(c *gin.Context) {
|
surveysRoutes.GET("/score", func(c *gin.Context) {
|
||||||
var u *User
|
var u *User
|
||||||
|
|
@ -85,7 +81,34 @@ func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
||||||
}
|
}
|
||||||
s := c.MustGet("survey").(*Survey)
|
s := c.MustGet("survey").(*Survey)
|
||||||
|
|
||||||
if (s.Promo == u.Promo && s.Shown) || (u != nil && u.IsAdmin) {
|
if u.IsAdmin {
|
||||||
|
questions, err := s.GetQuestions()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to getQuestions:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve questions. Please try again later."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
itemCount := 0
|
||||||
|
itemCorrected := 0
|
||||||
|
for _, q := range questions {
|
||||||
|
res, err := q.GetResponses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to GetResponses(qid=%d): %s", q.Id, err.Error())
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during responses retrieval."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range res {
|
||||||
|
itemCount += 1
|
||||||
|
if r.TimeScored != nil && (r.TimeReported == nil || r.TimeScored.After(*r.TimeReported)) {
|
||||||
|
itemCorrected += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, map[string]int{"count": itemCount, "corrected": itemCorrected})
|
||||||
|
} else if s.Promo == u.Promo && s.Shown {
|
||||||
score, err := s.GetScore(u)
|
score, err := s.GetScore(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Unable to GetScore(uid=%d;sid=%d): %s", u.Id, s.Id, err.Error())
|
log.Printf("Unable to GetScore(uid=%d;sid=%d): %s", u.Id, s.Id, err.Error())
|
||||||
|
|
@ -96,7 +119,7 @@ func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
||||||
if score == nil {
|
if score == nil {
|
||||||
c.JSON(http.StatusOK, map[string]string{"score": "N/A"})
|
c.JSON(http.StatusOK, map[string]string{"score": "N/A"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusOK, map[string]float64{"score": *score})
|
c.JSON(http.StatusOK, map[string]float64{"score": math.Round(*score*10) / 10})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
||||||
|
|
@ -123,7 +146,7 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) {
|
||||||
new.Promo = currentPromo
|
new.Promo = currentPromo
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, err := NewSurvey(new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil {
|
if s, err := NewSurvey(new.IdCategory, new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil {
|
||||||
log.Println("Unable to NewSurvey:", err)
|
log.Println("Unable to NewSurvey:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey creation: %s", err.Error())})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey creation: %s", err.Error())})
|
||||||
return
|
return
|
||||||
|
|
@ -181,6 +204,33 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
surveysRoutes.GET("shares", func(c *gin.Context) {
|
||||||
|
survey := c.MustGet("survey").(*Survey)
|
||||||
|
|
||||||
|
if sh, err := survey.getShares(); err != nil {
|
||||||
|
log.Println("Unable to getShares survey:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey shares listing: %s", err.Error())})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, sh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
surveysRoutes.POST("shares", func(c *gin.Context) {
|
||||||
|
survey := c.MustGet("survey").(*Survey)
|
||||||
|
|
||||||
|
if sh, err := survey.Share(); err != nil {
|
||||||
|
log.Println("Unable to Share survey:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())})
|
||||||
|
return
|
||||||
|
} else if url, err := sh.GetURL(); err != nil {
|
||||||
|
log.Println("Unable to GetURL share:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, url.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
declareAPIAdminAsksRoutes(surveysRoutes)
|
declareAPIAdminAsksRoutes(surveysRoutes)
|
||||||
declareAPIAdminDirectRoutes(surveysRoutes)
|
declareAPIAdminDirectRoutes(surveysRoutes)
|
||||||
declareAPIAdminQuestionsRoutes(surveysRoutes)
|
declareAPIAdminQuestionsRoutes(surveysRoutes)
|
||||||
|
|
@ -199,22 +249,25 @@ func surveyHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Survey) checkUserAccessToSurvey(u *User) bool {
|
||||||
|
return u.IsAdmin || (u.Promo == s.Promo && s.Shown && (s.Group == "" || strings.Contains(u.Groups, ","+s.Group+",")))
|
||||||
|
}
|
||||||
|
|
||||||
func surveyUserAccessHandler(c *gin.Context) {
|
func surveyUserAccessHandler(c *gin.Context) {
|
||||||
u := c.MustGet("LoggedUser").(*User)
|
u := c.MustGet("LoggedUser").(*User)
|
||||||
w := c.MustGet("survey").(*Survey)
|
s := c.MustGet("survey").(*Survey)
|
||||||
|
|
||||||
if u.IsAdmin {
|
if !s.checkUserAccessToSurvey(u) {
|
||||||
c.Next()
|
|
||||||
} else if w.Shown && (w.Group == "" || strings.Contains(u.Groups, ","+w.Group+",")) {
|
|
||||||
c.Next()
|
|
||||||
} else {
|
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Survey not found."})
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Survey not found."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Survey struct {
|
type Survey struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
|
IdCategory int64 `json:"id_category"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Promo uint `json:"promo"`
|
Promo uint `json:"promo"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
|
|
@ -226,14 +279,14 @@ type Survey struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSurveys(cnd string, param ...interface{}) (surveys []*Survey, err error) {
|
func getSurveys(cnd string, param ...interface{}) (surveys []*Survey, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil {
|
if rows, errr := DBQuery("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Survey
|
var s Survey
|
||||||
if err = rows.Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil {
|
if err = rows.Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
surveys = append(surveys, &s)
|
surveys = append(surveys, &s)
|
||||||
|
|
@ -255,7 +308,7 @@ func getSurvey(id int) (s *Survey, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
s = new(Survey)
|
s = new(Survey)
|
||||||
err = DBQueryRow("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability)
|
err = DBQueryRow("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability)
|
||||||
|
|
||||||
_surveys_cache_mutex.Lock()
|
_surveys_cache_mutex.Lock()
|
||||||
_surveys_cache[int64(id)] = s
|
_surveys_cache[int64(id)] = s
|
||||||
|
|
@ -263,13 +316,13 @@ func getSurvey(id int) (s *Survey, err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSurvey(title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) {
|
func NewSurvey(id_category int64, title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) {
|
||||||
if res, err := DBExec("INSERT INTO surveys (title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?)", title, promo, group, shown, direct, startAvailability, endAvailability); err != nil {
|
if res, err := DBExec("INSERT INTO surveys (id_category, title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id_category, title, promo, group, shown, direct, startAvailability, endAvailability); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if sid, err := res.LastInsertId(); err != nil {
|
} else if sid, err := res.LastInsertId(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
return &Survey{sid, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil
|
return &Survey{sid, id_category, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +342,7 @@ func (s Survey) GetScore(u *User) (score *float64, err error) {
|
||||||
if ok {
|
if ok {
|
||||||
score = v
|
score = v
|
||||||
} else {
|
} else {
|
||||||
err = DBQueryRow("SELECT SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? AND id_user=?", s.Id, u.Id).Scan(&score)
|
err = DBQueryRow("SELECT SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id=? AND id_user=?", s.Id, u.Id).Scan(&score)
|
||||||
if score != nil {
|
if score != nil {
|
||||||
*score = *score / 5.0
|
*score = *score / 5.0
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +355,7 @@ func (s Survey) GetScore(u *User) (score *float64, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Survey) GetScores() (scores map[int64]*float64, err error) {
|
func (s Survey) GetScores() (scores map[int64]*float64, err error) {
|
||||||
if rows, errr := DBQuery("SELECT id_user, SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? GROUP BY id_user", s.Id); errr != nil {
|
if rows, errr := DBQuery("SELECT id_user, SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id_survey=? GROUP BY id_user", s.Id); errr != nil {
|
||||||
return nil, errr
|
return nil, errr
|
||||||
} else {
|
} else {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
@ -327,7 +380,7 @@ func (s Survey) GetScores() (scores map[int64]*float64, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Survey) Update() (*Survey, error) {
|
func (s *Survey) Update() (*Survey, error) {
|
||||||
if _, err := DBExec("UPDATE surveys SET title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil {
|
if _, err := DBExec("UPDATE surveys SET id_category = ?, title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.IdCategory, s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else {
|
} else {
|
||||||
_surveys_cache_mutex.Lock()
|
_surveys_cache_mutex.Lock()
|
||||||
|
|
@ -338,6 +391,7 @@ func (s *Survey) Update() (*Survey, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Survey) Delete() (int64, error) {
|
func (s Survey) Delete() (int64, error) {
|
||||||
|
DBExec("DELETE FROM survey_shared WHERE id_survey = ?", s.Id)
|
||||||
if res, err := DBExec("DELETE FROM surveys WHERE id_survey = ?", s.Id); err != nil {
|
if res, err := DBExec("DELETE FROM surveys WHERE id_survey = ?", s.Id); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
} else if nb, err := res.RowsAffected(); err != nil {
|
} else if nb, err := res.RowsAffected(); err != nil {
|
||||||
|
|
|
||||||
4302
ui/package-lock.json
generated
4302
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,25 +11,26 @@
|
||||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^1.0.0-next.29",
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
"@sveltejs/kit": "^1.0.0-next.324",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"eslint": "^8.14.0",
|
"eslint": "^8.14.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-svelte3": "^4.0.0",
|
"eslint-plugin-svelte": "^2.35.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^3.0.0",
|
||||||
"prettier-plugin-svelte": "^2.7.0",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"svelte": "^3.48.0",
|
"svelte": "^4.0.0",
|
||||||
"svelte-check": "^2.7.0",
|
"svelte-check": "^3.4.3",
|
||||||
"svelte-preprocess": "^4.10.6",
|
"svelte-preprocess": "^5.0.3",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"typescript": "^4.6.4"
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.5",
|
"dayjs": "^1.11.5",
|
||||||
"svelte-frappe-charts": "^1.9.1",
|
"svelte-frappe-charts": "^1.9.1"
|
||||||
"vite": "^3.0.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<script>
|
|
||||||
export let survey;
|
|
||||||
let className = '';
|
|
||||||
export { className as class };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if survey.direct != null}<span class="badge bg-danger {className}">Direct</span>
|
|
||||||
{:else if survey.startAvailability() > Date.now()}<span class="badge bg-info {className}">Prévu</span>
|
|
||||||
{:else if survey.endAvailability() > Date.now()}<span class="badge bg-warning {className}">En cours</span>
|
|
||||||
{:else if !survey.__start_availability}<span class="badge bg-dark {className}">Nouveau</span>
|
|
||||||
{:else if !survey.corrected}<span class="badge bg-primary text-light {className}">Terminé</span>
|
|
||||||
{:else}<span class="badge bg-success {className}">Corrigé</span>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export async function handle({ event, resolve }) {
|
|
||||||
const response = await resolve(event, {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
60
ui/src/lib/categories.js
Normal file
60
ui/src/lib/categories.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
export async function getCategories() {
|
||||||
|
let url = '/api/categories';
|
||||||
|
const res = await fetch(url, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
return (await res.json()).map((r) => new Category(r));
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Category {
|
||||||
|
constructor(res) {
|
||||||
|
if (res) {
|
||||||
|
this.update(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ id, label, promo, expand }) {
|
||||||
|
this.id = id;
|
||||||
|
this.label = label;
|
||||||
|
this.promo = promo;
|
||||||
|
this.expand = expand;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const res = await fetch(this.id?`api/categories/${this.id}`:'api/categories', {
|
||||||
|
method: this.id?'PUT':'POST',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
body: JSON.stringify(this),
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json()
|
||||||
|
this.update(data);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
const res = await fetch(`api/categories/${this.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategory(cid) {
|
||||||
|
const res = await fetch(`api/categories/${cid}`, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
return new Category(await res.json());
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
{:else if state.status == "success"}
|
{:else if state.status == "success"}
|
||||||
<i class="bi bi-check-circle-fill text-success mx-1" title="La récupération s'est bien passée"></i>
|
<i class="bi bi-check-circle-fill text-success mx-1" title="La récupération s'est bien passée"></i>
|
||||||
{:else if state.status == "failure" || state.status == "killed"}
|
{:else if state.status == "failure" || state.status == "killed"}
|
||||||
<i class="bi bi-exclamation-circle-fill text-danger mx-1" title="La récupération ne s'est pas bien passée" style="cursor: pointer" on:click={() => dispatch('show_logs')}></i>
|
<i class="bi bi-exclamation-circle-fill text-danger mx-1" title="La récupération ne s'est pas bien passée" style="cursor: pointer" on:click={() => dispatch('show_logs')} on:keypress={() => dispatch('show_logs')}></i>
|
||||||
{:else}
|
{:else}
|
||||||
{state.status}
|
{state.status}
|
||||||
{/if}
|
{/if}
|
||||||
92
ui/src/lib/components/CategoryAdmin.svelte
Normal file
92
ui/src/lib/components/CategoryAdmin.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import DateTimeInput from './DateTimeInput.svelte';
|
||||||
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
export let category = null;
|
||||||
|
|
||||||
|
function saveCategory() {
|
||||||
|
category.save().then((response) => {
|
||||||
|
dispatch('saved', response);
|
||||||
|
}, (error) => {
|
||||||
|
ToastsStore.addErrorToast({
|
||||||
|
msg: error,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCategory() {
|
||||||
|
category.delete().then((response) => {
|
||||||
|
goto(`categories`);
|
||||||
|
}, (error) => {
|
||||||
|
ToastsStore.addErrorToast({
|
||||||
|
msg: error,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateCategory() {
|
||||||
|
category.duplicate().then((response) => {
|
||||||
|
goto(`categories/${response.id}`);
|
||||||
|
}).catch((error) => {
|
||||||
|
ToastsStore.addErrorToast({
|
||||||
|
msg: error,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={saveCategory}>
|
||||||
|
|
||||||
|
{#if category.id}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="cid" class="col-form-label col-form-label-sm">Identifiant de la catégorie</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" id="cid" value={category.id}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="title" class="col-form-label col-form-label-sm">Titre de la catégorie</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="title" bind:value={category.label}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||||
|
<input type="number" step="1" min="0" max="2068" class="form-control form-control-sm" id="promo" bind:value={category.promo}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-3 mx-1 my-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="expand" bind:checked={category.expand}>
|
||||||
|
<label class="form-check-label" for="expand">
|
||||||
|
Étendre par défaut
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||||
|
{#if category.id}
|
||||||
|
<button type="button" class="btn btn-danger" on:click={deleteCategory}>Supprimer</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
export let question = null;
|
export let question = null;
|
||||||
|
|
||||||
function refreshProposals() {
|
function refreshProposals() {
|
||||||
let req = question.getProposals();
|
let req = question.getProposals(secret);
|
||||||
|
|
||||||
req.then((proposals) => {
|
req.then((proposals) => {
|
||||||
const proposal_idx = { };
|
const proposal_idx = { };
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
proposal_idx[proposal.id] = new String(data.labels.length - 1);
|
proposal_idx[proposal.id] = new String(data.labels.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
req_responses = question.getResponses();
|
req_responses = question.getResponses(secret);
|
||||||
req_responses.then((responses) => {
|
req_responses.then((responses) => {
|
||||||
for (const res of responses) {
|
for (const res of responses) {
|
||||||
const rsplt = res.value.split(',');
|
const rsplt = res.value.split(',');
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
}
|
}
|
||||||
let req_proposals = null;
|
let req_proposals = null;
|
||||||
export let proposals = null;
|
export let proposals = null;
|
||||||
|
export let secret = null;
|
||||||
let req_responses = null;
|
let req_responses = null;
|
||||||
let mean = null;
|
let mean = null;
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@
|
||||||
|
|
||||||
if (!proposals) {
|
if (!proposals) {
|
||||||
if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) {
|
if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) {
|
||||||
req_responses = question.getResponses();
|
req_responses = question.getResponses(secret);
|
||||||
req_responses.then((responses) => {
|
req_responses.then((responses) => {
|
||||||
const values = [];
|
const values = [];
|
||||||
const proposal_idx = { };
|
const proposal_idx = { };
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { CorrectionTemplate } from '../lib/correctionTemplates';
|
import { CorrectionTemplate } from '$lib/correctionTemplates';
|
||||||
|
|
||||||
let className = '';
|
let className = '';
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { autoCorrection } from '../lib/correctionTemplates';
|
import { autoCorrection } from '$lib/correctionTemplates';
|
||||||
|
|
||||||
export let cts = null;
|
export let cts = null;
|
||||||
export let rid = 0;
|
export let rid = 0;
|
||||||
|
|
@ -38,14 +38,37 @@
|
||||||
for (const t of templates) {
|
for (const t of templates) {
|
||||||
if (my_tpls[t.id] === undefined && cts[t.id.toString()]) {
|
if (my_tpls[t.id] === undefined && cts[t.id.toString()]) {
|
||||||
my_tpls[t.id] = cts[t.id.toString()][response.id_user] !== undefined;
|
my_tpls[t.id] = cts[t.id.toString()][response.id_user] !== undefined;
|
||||||
|
|
||||||
|
// Hack to autocorrect only if this has already been checked previously
|
||||||
|
if (autoCorrectionInProgress && cts[t.id.toString()][response.id_user] !== undefined) {
|
||||||
|
autoCorrectionInProgress = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let element = null;
|
||||||
|
let scrollY = 0;
|
||||||
|
let autoCorrectionInProgress = true;
|
||||||
|
$: {
|
||||||
|
if (element && scrollY > element.offsetParent.offsetTop - 500 && !my_correction) {
|
||||||
|
let tmp = false;
|
||||||
|
[tmp, autoCorrectionInProgress] = [autoCorrectionInProgress, true];
|
||||||
|
if (!tmp) {
|
||||||
|
autoCorrection(response.id_user, my_tpls).then((r) => {
|
||||||
|
my_correction = r;
|
||||||
|
autoCorrectionInProgress = false;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:scrollY={scrollY}/>
|
||||||
<form
|
<form
|
||||||
class="row"
|
class="row"
|
||||||
|
bind:this={element}
|
||||||
on:submit|preventDefault={submitCorrection}
|
on:submit|preventDefault={submitCorrection}
|
||||||
>
|
>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
|
@ -69,6 +92,7 @@
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
class="form-check-label"
|
class="form-check-label"
|
||||||
|
class:fw-bold={template.regexp && (template.regexp[0] == "!" ? !response.value.match(new RegExp(template.regexp.substring(1))) : response.value.match(new RegExp(template.regexp)))}
|
||||||
for="r{response.id}t{template.id}"
|
for="r{response.id}t{template.id}"
|
||||||
>
|
>
|
||||||
{template.label}
|
{template.label}
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
import QuestionProposals from './QuestionProposals.svelte';
|
import QuestionProposals from './QuestionProposals.svelte';
|
||||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||||
import CorrectionResponseFooter from './CorrectionResponseFooter.svelte';
|
import CorrectionResponseFooter from './CorrectionResponseFooter.svelte';
|
||||||
import { autoCorrection } from '../lib/correctionTemplates';
|
import { autoCorrection } from '$lib/correctionTemplates';
|
||||||
import { getUser } from '../lib/users';
|
import { getUser } from '$lib/users';
|
||||||
|
|
||||||
export let cts = null;
|
export let cts = null;
|
||||||
export let filter = "";
|
export let filter = "";
|
||||||
|
|
@ -35,20 +35,46 @@
|
||||||
filteredResponses = responses.filter((r) => (notCorrected || r.time_scored <= r.time_reported || !r.time_scored) && (!filter || ((filter[0] == '!' && !r.value.match(filter.substring(1))) || r.value.match(filter))));
|
filteredResponses = responses.filter((r) => (notCorrected || r.time_scored <= r.time_reported || !r.time_scored) && (!filter || ((filter[0] == '!' && !r.value.match(filter.substring(1))) || r.value.match(filter))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeTags(htmlStr) {
|
||||||
|
return htmlStr.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hilightText(input, templates) {
|
||||||
|
for (const { regexp } of templates) {
|
||||||
|
if (regexp) {
|
||||||
|
input = input.replace(new RegExp(regexp, 'g'), '<ins class="fw-bold">$&</ins>')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyCorrections() {
|
export async function applyCorrections() {
|
||||||
for (const r of filteredResponses) {
|
for (const r of filteredResponses) {
|
||||||
const my_correction = { };
|
const my_correction = { };
|
||||||
|
let has_no_lost_answer = false;
|
||||||
|
let completed_correction = false;
|
||||||
|
|
||||||
for (const tpl of templates) {
|
for (const tpl of templates) {
|
||||||
|
if (tpl.score >= 0) has_no_lost_answer = true;
|
||||||
if (!tpl.regexp && tpl.label) continue;
|
if (!tpl.regexp && tpl.label) continue;
|
||||||
|
|
||||||
if (tpl.regexp && (tpl.regexp[0] == '!' && !r.value.match(tpl.regexp.substring(1))) || r.value.match(tpl.regexp)) {
|
if (tpl.regexp && (tpl.regexp[0] == '!' && !r.value.match(tpl.regexp.substring(1))) || r.value.match(tpl.regexp)) {
|
||||||
my_correction[tpl.id] = true;
|
my_correction[tpl.id] = true;
|
||||||
|
completed_correction = true;
|
||||||
} else {
|
} else {
|
||||||
my_correction[tpl.id] = false;
|
my_correction[tpl.id] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no valid correction template AND valid answer is defined,
|
||||||
|
// don't consider the absence of match as valid answer.
|
||||||
|
if (!completed_correction && has_no_lost_answer) continue;
|
||||||
|
|
||||||
const auto = await autoCorrection(r.id_user, my_correction);
|
const auto = await autoCorrection(r.id_user, my_correction);
|
||||||
r.score = auto.score;
|
r.score = auto.score;
|
||||||
r.score_explaination = auto.score_explaination;
|
r.score_explaination = auto.score_explaination;
|
||||||
|
|
@ -88,7 +114,7 @@
|
||||||
class="card-text"
|
class="card-text"
|
||||||
style="white-space: pre-line"
|
style="white-space: pre-line"
|
||||||
>
|
>
|
||||||
{response.value}
|
{@html hilightText(escapeTags(response.value), templates)}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<ResponseCorrected
|
<ResponseCorrected
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{#each res[rep] as user}
|
{#each res[rep] as user}
|
||||||
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
|
<a href="users/{user}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
|
||||||
{#if users && users[user]}
|
{#if users && users[user]}
|
||||||
{users[user].login}
|
{users[user].login}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
<span>
|
<span>
|
||||||
{rep}
|
{rep}
|
||||||
</span>
|
</span>
|
||||||
<a href="users/{res[rep]}" target="_blank" class="badge bg-dark rounded-pill">
|
<a href="users/{res[rep]}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
|
||||||
{#if users && users[res[rep]]}
|
{#if users && users[res[rep]]}
|
||||||
{users[res[rep]].login}
|
{users[res[rep]].login}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import QuestionHeader from './QuestionHeader.svelte';
|
import QuestionHeader from './QuestionHeader.svelte';
|
||||||
import QuestionProposals from './QuestionProposals.svelte';
|
import QuestionProposals from './QuestionProposals.svelte';
|
||||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import { QuestionProposal } from '../lib/questions';
|
import { QuestionProposal } from '$lib/questions';
|
||||||
|
|
||||||
export let edit = false;
|
export let edit = false;
|
||||||
export let proposals = [];
|
export let proposals = [];
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
export let response = null;
|
export let response = null;
|
||||||
export let survey = null;
|
export let survey = null;
|
||||||
14
ui/src/lib/components/ScoreBadge.svelte
Normal file
14
ui/src/lib/components/ScoreBadge.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script>
|
||||||
|
export let score = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
class:bg-success={score >= 18}
|
||||||
|
class:bg-info={score < 18 && score >= 15}
|
||||||
|
class:bg-warning={score < 15 && score >= 9}
|
||||||
|
class:bg-danger={score < 9}
|
||||||
|
class:bg-dark={score == "N/A"}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { getSurveys } from '../lib/surveys';
|
import { getCategories } from '$lib/categories';
|
||||||
import { getUsers, getGrades, getPromos } from '../lib/users';
|
import { getSurveys } from '$lib/surveys';
|
||||||
|
import { getUsers, getGrades, getPromos } from '$lib/users';
|
||||||
|
|
||||||
export let promo = null;
|
export let promo = null;
|
||||||
|
export let category = null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await getPromos() then promos}
|
{#await getPromos() then promos}
|
||||||
|
|
@ -15,12 +17,24 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
|
{#await getCategories() then categories}
|
||||||
|
<div class="float-end me-2">
|
||||||
|
<select class="form-select" bind:value={category}>
|
||||||
|
<option value={null}>toutes</option>
|
||||||
|
{#each categories as categ (categ.id)}
|
||||||
|
{#if !promo || categ.promo == promo}
|
||||||
|
<option value={categ.id}>{categ.label}</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
<h2>
|
<h2>
|
||||||
Étudiants {#if promo !== null}{promo}{/if}
|
Étudiants {#if promo !== null}{promo}{/if}
|
||||||
<small class="text-muted">Notes</small>
|
<small class="text-muted">Notes</small>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#await getSurveys()}
|
{#await getSurveys(true)}
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
<div class="spinner-border me-2" role="status"></div>
|
<div class="spinner-border me-2" role="status"></div>
|
||||||
Chargement des questionnaires corrigés…
|
Chargement des questionnaires corrigés…
|
||||||
|
|
@ -38,9 +52,15 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Login</th>
|
<th>Login</th>
|
||||||
{#each surveys as survey (survey.id)}
|
{#each surveys as survey}
|
||||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
|
||||||
<th><a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a></th>
|
<th>
|
||||||
|
{#if survey.kind == "survey"}
|
||||||
|
<a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a>
|
||||||
|
{:else}
|
||||||
|
<a href="works/{survey.id}" style="text-decoration: none">{survey.title}</a>
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -57,13 +77,15 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:then users}
|
{:then users}
|
||||||
{#each users as user (user.id)}
|
{#each users as user (user.id)}
|
||||||
{#if promo === null || user.promo === promo}
|
{#if !promo || user.promo == promo}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="users/{user.id}" style="text-decoration: none">{user.id}</a></td>
|
<td><a href="users/{user.id}" style="text-decoration: none">{user.id}</a></td>
|
||||||
<td><a href="users/{user.login}" style="text-decoration: none">{user.login}</a></td>
|
<td><a href="users/{user.login}" style="text-decoration: none">{user.login}</a></td>
|
||||||
{#each surveys as survey (survey.id)}
|
{#each surveys as survey}
|
||||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
|
||||||
<td>{grades[user.id] && grades[user.id][survey.id]?grades[user.id][survey.id]:""}</td>
|
<td>
|
||||||
|
{grades[user.id] && grades[user.id][survey.kind + "." + survey.id]?grades[user.id][survey.kind + "." + survey.id]:""}
|
||||||
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import DateFormat from '../components/DateFormat.svelte';
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
import { getUserRendu } from '../lib/works';
|
import { getUserRendu } from '$lib/works';
|
||||||
|
|
||||||
let className = '';
|
|
||||||
export { className as class };
|
|
||||||
|
|
||||||
export let work = null;
|
export let work = null;
|
||||||
export let user = null;
|
export let user = null;
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
import { getQuestions } from '../lib/questions';
|
import { getCategories } from '$lib/categories';
|
||||||
|
import { getQuestions } from '$lib/questions';
|
||||||
import DateTimeInput from './DateTimeInput.svelte';
|
import DateTimeInput from './DateTimeInput.svelte';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
export let survey = null;
|
export let survey = null;
|
||||||
|
|
@ -33,10 +34,14 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let duplicateInProgress = false;
|
||||||
function duplicateSurvey() {
|
function duplicateSurvey() {
|
||||||
|
duplicateInProgress = true;
|
||||||
survey.duplicate().then((response) => {
|
survey.duplicate().then((response) => {
|
||||||
|
duplicateInProgress = false;
|
||||||
goto(`surveys/${response.id}`);
|
goto(`surveys/${response.id}`);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
duplicateInProgress = false;
|
||||||
ToastsStore.addErrorToast({
|
ToastsStore.addErrorToast({
|
||||||
msg: error,
|
msg: error,
|
||||||
});
|
});
|
||||||
|
|
@ -67,6 +72,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="category" class="col-form-label col-form-label-sm">Catégorie</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||||
|
{#await getCategories() then categories}
|
||||||
|
<select id="category" class="form-select form-select-sm" bind:value={survey.id_category}>
|
||||||
|
{#each categories as category (category.id)}
|
||||||
|
<option value={category.id}>{category.label} {category.promo}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
||||||
|
|
@ -108,7 +128,7 @@
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8 col-md-5 col-lg-3">
|
||||||
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={survey.start_availability} />
|
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={survey.start_availability} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,7 +137,7 @@
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-sm-8 col-md-5 col-lg-3">
|
||||||
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={survey.end_availability} />
|
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={survey.end_availability} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,14 +161,19 @@
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||||
{#if survey.id}
|
{#if survey.id || duplicateInProgress}
|
||||||
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress}>
|
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress || duplicateInProgress}>
|
||||||
{#if deleteInProgress}
|
{#if deleteInProgress}
|
||||||
<div class="spinner-border spinner-border-sm text-light me-1" role="status"></div>
|
<div class="spinner-border spinner-border-sm text-light me-1" role="status"></div>
|
||||||
{/if}
|
{/if}
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey}>Dupliquer avec ces nouveaux paramètres</button>
|
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey} disabled={duplicateInProgress}>
|
||||||
|
{#if duplicateInProgress}
|
||||||
|
<div class="spinner-border spinner-border-sm text-dark me-1" role="status"></div>
|
||||||
|
{/if}
|
||||||
|
Dupliquer avec ces nouveaux paramètres
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
13
ui/src/lib/components/SurveyBadge.svelte
Normal file
13
ui/src/lib/components/SurveyBadge.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script>
|
||||||
|
export let survey;
|
||||||
|
let className = '';
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if survey.direct != null}<span class="badge bg-danger {className}">Direct</span>
|
||||||
|
{:else if survey.startAvailability() > Date.now()}<span class="badge bg-info {className}" title="Le questionnaire ouvre le {survey.startAvailability()}">Prévu</span>
|
||||||
|
{:else if survey.endAvailability() > Date.now()}<span class="badge bg-warning {className}" title="Le questionnaire se termine le {survey.endAvailability()}">En cours</span>
|
||||||
|
{:else if !survey.__start_availability}<span class="badge bg-dark {className}">Nouveau</span>
|
||||||
|
{:else if !survey.corrected}<span class="badge bg-primary text-light {className}" title="Le questionnaire s'est terminé le {survey.endAvailability()}">Terminé</span>
|
||||||
|
{:else}<span class="badge bg-success {className}" title="Le questionnaire s'est terminé le {survey.endAvailability()} et est désormais corrigé">Corrigé</span>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import DateFormat from '../components/DateFormat.svelte';
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
import SurveyBadge from '../components/SurveyBadge.svelte';
|
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
|
||||||
import SubmissionStatus from '../components/SubmissionStatus.svelte';
|
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
|
||||||
import { getSurveys } from '../lib/surveys';
|
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
|
||||||
import { getScore } from '../lib/users';
|
import { getCategories } from '$lib/categories';
|
||||||
|
import { getSurveys } from '$lib/surveys';
|
||||||
|
import { getScore } from '$lib/users';
|
||||||
|
|
||||||
export let allworks = false;
|
export let allworks = false;
|
||||||
|
|
||||||
|
|
@ -21,6 +23,13 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let categories = {};
|
||||||
|
getCategories().then((cs) => {
|
||||||
|
for (const c of cs) {
|
||||||
|
categories[c.id] = c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function gotoSurvey(survey) {
|
function gotoSurvey(survey) {
|
||||||
if (survey.kind === "w") {
|
if (survey.kind === "w") {
|
||||||
goto(`works/${survey.id}`);
|
goto(`works/${survey.id}`);
|
||||||
|
|
@ -40,7 +49,11 @@
|
||||||
<th>Intitulé</th>
|
<th>Intitulé</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
{#if $user}
|
{#if $user}
|
||||||
<th>Score</th>
|
{#if $user.is_admin}
|
||||||
|
<th>À corriger</th>
|
||||||
|
{:else}
|
||||||
|
<th>Score</th>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -56,13 +69,39 @@
|
||||||
{#each surveys as survey, sid (survey.kind + survey.id)}
|
{#each surveys as survey, sid (survey.kind + survey.id)}
|
||||||
{#if (survey.shown || survey.direct == null || ($user && $user.is_admin)) && (!$user || (!$user.was_admin || $user.promo == survey.promo) || $user.is_admin)}
|
{#if (survey.shown || survey.direct == null || ($user && $user.is_admin)) && (!$user || (!$user.was_admin || $user.promo == survey.promo) || $user.is_admin)}
|
||||||
{#if $user && $user.is_admin && (sid == 0 || surveys[sid-1].promo != survey.promo)}
|
{#if $user && $user.is_admin && (sid == 0 || surveys[sid-1].promo != survey.promo)}
|
||||||
<tr class="bg-info text-light">
|
<tr class="bg-warning text-light">
|
||||||
<th colspan="5" class="fw-bold">
|
<th colspan="5" class="fw-bold">
|
||||||
{survey.promo}
|
{survey.promo}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
<tr on:click={e => gotoSurvey(survey)}>
|
{#if $user && (sid == 0 || surveys[sid-1].id_category != survey.id_category) && categories[survey.id_category]}
|
||||||
|
<tr class="bg-primary text-light">
|
||||||
|
<th
|
||||||
|
colspan="5"
|
||||||
|
class="fw-bold"
|
||||||
|
on:click={() => categories[survey.id_category].expand = !categories[survey.id_category].expand}
|
||||||
|
on:keypress={() => categories[survey.id_category].expand = !categories[survey.id_category].expand}
|
||||||
|
>
|
||||||
|
{#if categories[survey.id_category].expand}
|
||||||
|
<i class="bi bi-chevron-down"></i>
|
||||||
|
{:else}
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
{/if}
|
||||||
|
{categories[survey.id_category].label}
|
||||||
|
{#if $user && $user.is_admin}
|
||||||
|
<a href="categories/{survey.id_category}" class="float-end btn btn-sm btn-light" style="margin: -6px;">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if categories[survey.id_category] && categories[survey.id_category].expand}
|
||||||
|
<tr
|
||||||
|
on:click={e => gotoSurvey(survey)}
|
||||||
|
on:keypress={e => gotoSurvey(survey)}
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
{#if !survey.shown}<i class="bi bi-eye-slash-fill" title="Ce questionnaire n'est pas affiché aux étudiants"></i>{/if}
|
{#if !survey.shown}<i class="bi bi-eye-slash-fill" title="Ce questionnaire n'est pas affiché aux étudiants"></i>{/if}
|
||||||
{survey.title}
|
{survey.title}
|
||||||
|
|
@ -84,14 +123,30 @@
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $user}
|
{#if $user}
|
||||||
{#if !survey.corrected}
|
{#if !survey.corrected && !$user.is_admin}
|
||||||
<td>N/A</td>
|
<td>N/A</td>
|
||||||
{:else}
|
{:else}
|
||||||
<td>
|
<td>
|
||||||
{#await getScore(survey)}
|
{#await getScore(survey)}
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
{:then score}
|
{:then score}
|
||||||
{score.score}
|
{#if score.count !== undefined}
|
||||||
|
<span
|
||||||
|
class:fw-bolder={score.count-score.corrected > 0}
|
||||||
|
class:badge={survey.corrected}
|
||||||
|
class:bg-danger={survey.corrected && score.count-score.corrected > 0}
|
||||||
|
class:bg-dark={survey.corrected && score.count-score.corrected <= 0}
|
||||||
|
title="{score.count-score.corrected}/{score.count}"
|
||||||
|
>
|
||||||
|
{#if score.count == 0 || score.corrected == 0 || survey.corrected}
|
||||||
|
{score.count-score.corrected}
|
||||||
|
{:else}
|
||||||
|
{Math.trunc((1-score.corrected/score.count)*100)} %
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<ScoreBadge score={score.score} />
|
||||||
|
{/if}
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
@ -99,6 +154,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
import QuestionForm from '../components/QuestionForm.svelte';
|
import QuestionForm from '$lib/components/QuestionForm.svelte';
|
||||||
import { Question } from '../lib/questions';
|
import { Question } from '$lib/questions';
|
||||||
|
|
||||||
export let survey = null;
|
export let survey = null;
|
||||||
export let id_user = null;
|
export let id_user = null;
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
survey.submitAnswers(res, id_user).then((response) => {
|
survey.submitAnswers(res, id_user).then((response) => {
|
||||||
submitInProgress = false;
|
submitInProgress = false;
|
||||||
ToastsStore.addToast({
|
ToastsStore.addToast({
|
||||||
msg: "Vos réponses ont bien étés sauvegardées.",
|
msg: "Vos réponses ont bien été sauvegardées.",
|
||||||
color: "success",
|
color: "success",
|
||||||
title: "Questionnaire",
|
title: "Questionnaire",
|
||||||
});
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
15
ui/src/lib/components/TraceStatus.svelte
Normal file
15
ui/src/lib/components/TraceStatus.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script>
|
||||||
|
export let status = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if status}
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
class:bg-success={status == "success"}
|
||||||
|
class:bg-danger={status == "failure" || status == "killed"}
|
||||||
|
class:bg-warning={status == "pending" || status == "running"}
|
||||||
|
class:bg-dark={status != "success" && status != "failure" && status != "killed" && status != "pending" && status != "running"}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getKeys, getKey, Key } from '../lib/key';
|
import { getKeys, getKey, Key } from '$lib/key';
|
||||||
|
|
||||||
export let student = null;
|
export let student = null;
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
import { getSurveys } from '../lib/surveys';
|
import { getSurveys } from '$lib/surveys';
|
||||||
import { getUser, getUserGrade, getUserScore } from '../lib/users';
|
import { getUser, getUserGrade, getUserScore } from '$lib/users';
|
||||||
|
|
||||||
export let student = null;
|
export let student = null;
|
||||||
export let allPromos = false;
|
export let allPromos = false;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import DateFormat from '../components/DateFormat.svelte';
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
|
|
||||||
let className = '';
|
let className = '';
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { getCategories } from '$lib/categories';
|
||||||
|
import { getGradationRepositories, syncGradationRepositories } from '$lib/gradation';
|
||||||
import DateTimeInput from './DateTimeInput.svelte';
|
import DateTimeInput from './DateTimeInput.svelte';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
export let work = null;
|
export let work = null;
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let grepositoriesP = getGradationRepositories();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit|preventDefault={saveWork}>
|
<form on:submit|preventDefault={saveWork}>
|
||||||
|
|
@ -62,6 +65,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="category" class="col-form-label col-form-label-sm">Catégorie</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||||
|
{#await getCategories() then categories}
|
||||||
|
<select id="category" class="form-select form-select-sm" bind:value={work.id_category}>
|
||||||
|
{#each categories as category (category.id)}
|
||||||
|
<option value={category.id}>{category.label} {category.promo}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
||||||
|
|
@ -93,16 +111,46 @@
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="submissionurl" class="col-form-label col-form-label-sm">URL validation la soumission</label>
|
<label for="submissionurl" class="col-form-label col-form-label-sm">URL validation la soumission</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
<div class="col-sm-10 col-md-8 col-lg-4">
|
||||||
<input class="form-control form-control-sm" id="submissionurl" bind:value={work.submission_url}>
|
<input class="form-control form-control-sm" id="submissionurl" bind:value={work.submission_url}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3 text-sm-end">
|
||||||
|
<label for="gradationrepo" class="col-form-label col-form-label-sm">Dépôt des tests automatiques</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-10 col-md-8 col-lg-4 d-flex align-items-center">
|
||||||
|
{#await grepositoriesP}
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
{:then grepositories}
|
||||||
|
<div class="input-group">
|
||||||
|
<select class="form-select form-select-sm" id="gradationrepo" bind:value={work.gradation_repo}>
|
||||||
|
<option value={null}>-</option>
|
||||||
|
{#each grepositories as r}
|
||||||
|
<option value={r.slug}>{r.slug}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-light btn-sm"
|
||||||
|
title="Synchroniser les dépôts"
|
||||||
|
on:click={() => grepositoriesP = syncGradationRepositories()}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8 col-md-5 col-lg-3">
|
||||||
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={work.start_availability} />
|
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={work.start_availability} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,7 +159,7 @@
|
||||||
<div class="col-sm-3 text-sm-end">
|
<div class="col-sm-3 text-sm-end">
|
||||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-sm-8 col-md-5 col-lg-3">
|
||||||
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={work.end_availability} />
|
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={work.end_availability} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
192
ui/src/lib/components/WorkGrades.svelte
Normal file
192
ui/src/lib/components/WorkGrades.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
|
||||||
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let work;
|
||||||
|
let gradesP = null;
|
||||||
|
let gradationStatus = {};
|
||||||
|
let stats = {"mean": 0, "min": 999, "max": 0};
|
||||||
|
|
||||||
|
let chgrade = {grade: null, modal: null};
|
||||||
|
|
||||||
|
$: refresh_grades(work);
|
||||||
|
|
||||||
|
function refresh_grades(w) {
|
||||||
|
gradesP = w.getGrades();
|
||||||
|
gradesP.then((grades) => {
|
||||||
|
if (grades.length <= 0) return;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (const grade of grades) {
|
||||||
|
if (!gradationStatus[grade.id])
|
||||||
|
gradationStatus[grade.id] = grade.gradationStatus();
|
||||||
|
|
||||||
|
sum += grade.score;
|
||||||
|
if (stats.min > grade.score && grade.comment != "- Non rendu -") stats.min = grade.score;
|
||||||
|
if (stats.max < grade.score) stats.max = grade.score;
|
||||||
|
}
|
||||||
|
stats.mean = sum / grades.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMissingStudents(w) {
|
||||||
|
await w.addMissingGrades();
|
||||||
|
refresh_grades(w);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="mt-3">
|
||||||
|
Notes
|
||||||
|
<small class="text-muted">
|
||||||
|
{#if stats.mean > 0}(moyenne : {Math.round(stats.mean*100)/100}, min : {stats.min}, max : {stats.max}){/if}
|
||||||
|
</small>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
title="Afficher le résumé par étapes"
|
||||||
|
on:click={() => dispatch("switch_steps")}
|
||||||
|
>
|
||||||
|
<i class="bi bi-bar-chart-steps"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-info"
|
||||||
|
title="Ajouter les étudiants manquant"
|
||||||
|
on:click={() => addMissingStudents(work)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-light"
|
||||||
|
title="Rafraîchir l'affichage des notes"
|
||||||
|
on:click={() => refresh_grades(work)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-3 mb-5">
|
||||||
|
{#await gradesP}
|
||||||
|
<div class="text-center my-5">
|
||||||
|
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||||
|
<span>Chargement des notes …</span>
|
||||||
|
</div>
|
||||||
|
{:then grades}
|
||||||
|
<table class="table table-hover table-striped table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Commentaire</th>
|
||||||
|
<th>Date de la note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if !grades}
|
||||||
|
<div class="text-center">
|
||||||
|
Aucune note n'a encore été envoyée pour ce travail.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grades as grade, gid (grade.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="users/{grade.id_user}">{grade.login}</a>
|
||||||
|
</td>
|
||||||
|
<td><ScoreBadge score={grade.score} /></td>
|
||||||
|
<td>{#if grade.comment}{grade.comment}{:else}-{/if}</td>
|
||||||
|
<td>{grade.date}</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="/api/users/{grade.id_user}/works/{work.id}/grades/{grade.id}/traces"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-info mr-1"
|
||||||
|
title="Voir le détail de la notation"
|
||||||
|
>
|
||||||
|
<i class="bi bi-list-check"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/api/users/{grade.id_user}/works/{work.id}/grades/{grade.id}/forge"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary mr-1"
|
||||||
|
title="Voir le contenu du dépôt lié"
|
||||||
|
>
|
||||||
|
<i class="bi bi-git"></i>
|
||||||
|
</a>
|
||||||
|
{#if gradationStatus[grade.id]}
|
||||||
|
{#await gradationStatus[grade.id]}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-success mr-1"
|
||||||
|
title="Relancer la notation"
|
||||||
|
on:click={() => { grade.redoGradation().then(() => gradationStatus[grade.id] = grade.gradationStatus()); }}
|
||||||
|
>
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
</button>
|
||||||
|
{:then status}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm mr-1"
|
||||||
|
class:btn-success={status.status == "success"}
|
||||||
|
class:btn-danger={status.status == "failure"}
|
||||||
|
class:btn-outline-danger={status.status == "killed"}
|
||||||
|
class:btn-outline-warning={status.status == "pending" || status.status == "running"}
|
||||||
|
title="Relancer la notation"
|
||||||
|
on:click={() => { grade.redoGradation(); gradationStatus[grade.id] = null; }}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary mr-1"
|
||||||
|
title="Changer la note"
|
||||||
|
on:click={() => { chgrade = { grade, modal: new bootstrap.Modal(document.getElementById('chgradeModal'))}; chgrade.modal.show(); }}
|
||||||
|
>
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-danger mr-1"
|
||||||
|
title="Supprimer la note"
|
||||||
|
on:click={() => { grade.delete().then(() => refresh_grades(work)); }}
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" tabindex="-1" id="chgradeModal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" on:submit|preventDefault={() => {chgrade.modal.hide(); try { chgrade.grade.save().then(() => refresh_grades(work)); } catch(err) { ToastsStore.addToast({color: "danger", title: "Impossible de changer la note", msg: err}) };}}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Changer la note {#if chgrade.grade}de {chgrade.grade.login}{/if}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if chgrade.grade}
|
||||||
|
<div class="form-group row mb-2">
|
||||||
|
<label class="col-2 col-form-label" for="new-grade">Note</label>
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
|
<input type="number" class="form-control col" id="new-grade" autofocus placeholder="15" bind:value={chgrade.grade.score}>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-2">
|
||||||
|
<label class="col-2 col-form-label" for="new-comment">Commentaire</label>
|
||||||
|
<input class="form-control col" id="new-comment" bind:value={chgrade.grade.comment}>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Changer la note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
214
ui/src/lib/components/WorkGradesSteps.svelte
Normal file
214
ui/src/lib/components/WorkGradesSteps.svelte
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let work;
|
||||||
|
let gradesP = null;
|
||||||
|
let grade_idx = {};
|
||||||
|
let gradationStatus = {};
|
||||||
|
|
||||||
|
let stats = [];
|
||||||
|
|
||||||
|
$: refresh_grades(work);
|
||||||
|
|
||||||
|
function refresh_grades(w) {
|
||||||
|
gradesP = w.getGrades();
|
||||||
|
gradesP.then((grades) => {
|
||||||
|
if (grades.length <= 0) return;
|
||||||
|
|
||||||
|
for (const grade of grades) {
|
||||||
|
grade_idx[grade.id] = grade;
|
||||||
|
if (!gradationStatus[grade.id]) {
|
||||||
|
gradationStatus[grade.id] = grade.gradationStatus();
|
||||||
|
gradationStatus[grade.id].then((status) => {
|
||||||
|
for (const istage in status.stages) {
|
||||||
|
const stage = status.stages[istage];
|
||||||
|
|
||||||
|
if (stats.length <= istage) {
|
||||||
|
stats.push({
|
||||||
|
arch: stage.arch,
|
||||||
|
name: stage.name,
|
||||||
|
number: stage.number,
|
||||||
|
status: [],
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stats[istage].status.push(stage.status);
|
||||||
|
|
||||||
|
for (const istep in stage.steps) {
|
||||||
|
const step = stage.steps[istep];
|
||||||
|
|
||||||
|
if (stats[istage].steps.length <= istep) {
|
||||||
|
stats[istage].steps.push({
|
||||||
|
name: step.name,
|
||||||
|
number: step.number,
|
||||||
|
status: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stats[istage].steps[istep].status.push(step.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats = stats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let view_step = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="mt-3">
|
||||||
|
Réussite des étapes
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
title="Afficher le résumé par étapes"
|
||||||
|
on:click={() => dispatch("switch_steps")}
|
||||||
|
>
|
||||||
|
<i class="bi bi-bar-chart-steps"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-light"
|
||||||
|
title="Rafraîchir l'affichage des notes"
|
||||||
|
on:click={() => refresh_grades(work)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each stats as stage, istage}
|
||||||
|
<h5>
|
||||||
|
{stage.name}
|
||||||
|
<small>{stage.arch}</small>
|
||||||
|
</h5>
|
||||||
|
<div class="row row-cols-5">
|
||||||
|
{#each stage.steps as step, istep}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body fw-bolder text-truncate" title={step.name}>
|
||||||
|
{step.number}. {step.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="card-footer text-center"
|
||||||
|
class:bg-success={step.status.filter((e) => e == "success").length/step.status.length > 0.5}
|
||||||
|
on:click={() => view_step = {istage, istep, status: "success"}}
|
||||||
|
>
|
||||||
|
<i class="bi bi-check me-2 fw-bolder"></i>
|
||||||
|
{step.status.filter((e) => e == "success").length}
|
||||||
|
({Math.trunc(step.status.filter((e) => e == "success").length*100/step.status.length)} %)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="card-footer text-center"
|
||||||
|
class:bg-danger={step.status.filter((e) => e == "failure").length/step.status.length >= 0.5}
|
||||||
|
on:click={() => view_step = {istage, istep, status: "failure"}}
|
||||||
|
>
|
||||||
|
<i class="bi bi-x me-2 fw-bolder"></i>
|
||||||
|
{step.status.filter((e) => e == "failure").length}
|
||||||
|
({Math.trunc(step.status.filter((e) => e == "failure").length*100/step.status.length)} %)
|
||||||
|
</div>
|
||||||
|
{#if step.status.filter((e) => e == "skipped").length > 0}
|
||||||
|
<div
|
||||||
|
class="card-footer text-center"
|
||||||
|
class:fw-bold={step.status.filter((e) => e == "skipped").length/step.status.length >= 0.5}
|
||||||
|
on:click={() => view_step = {istage, istep, status: "skipped"}}
|
||||||
|
>
|
||||||
|
<i class="bi bi-skip-end me-2 fw-bolder"></i>
|
||||||
|
{step.status.filter((e) => e == "skipped").length}
|
||||||
|
({Math.trunc(step.status.filter((e) => e == "skipped").length*100/step.status.length)} %)
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if view_step}
|
||||||
|
<h3>
|
||||||
|
Étudiants correspondant
|
||||||
|
<small class="text-muted">
|
||||||
|
{"{"}
|
||||||
|
{stats[view_step.istage].name}
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
<em>{stats[view_step.istage].steps[view_step.istep].name}</em>
|
||||||
|
"{view_step.status}"
|
||||||
|
{"}"}
|
||||||
|
</small>
|
||||||
|
</h3>
|
||||||
|
<div class="row row-cols-6">
|
||||||
|
{#each Object.keys(gradationStatus) as gsi}
|
||||||
|
{#await gradationStatus[gsi] then gs}
|
||||||
|
{#if gs.stages[view_step.istage] && gs.stages[view_step.istage].steps[view_step.istep] && gs.stages[view_step.istage].steps[view_step.istep].status == view_step.status}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div
|
||||||
|
class="card-header text-monospace text-truncate"
|
||||||
|
title={grade_idx[gsi].login}
|
||||||
|
>
|
||||||
|
<a href="/users/{grade_idx[gsi].id_user}">
|
||||||
|
{grade_idx[gsi].login}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{#each gs.stages[view_step.istage].steps as step}
|
||||||
|
<li
|
||||||
|
class="list-group-item text-truncate p-2"
|
||||||
|
class:bg-success={step.status == "success"}
|
||||||
|
class:bg-light={step.status == "skipped"}
|
||||||
|
class:bg-danger={step.status == "failure"}
|
||||||
|
class:bg-warning={step.status == "pending" || step.status == "running"}
|
||||||
|
class:bg-info={step.status == "killed"}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/traces/{gs.stages[view_step.istage].number}/{step.number}"
|
||||||
|
target="_blank"
|
||||||
|
title="Voir le détail de cette étape"
|
||||||
|
>
|
||||||
|
{step.number}.
|
||||||
|
</a>
|
||||||
|
{step.name}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
class="card-footer d-flex justify-content-around align-items-center px-0"
|
||||||
|
>
|
||||||
|
<ScoreBadge score={grade_idx[gsi].score} />
|
||||||
|
<a
|
||||||
|
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/traces"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-info"
|
||||||
|
title="Voir le détail de la notation"
|
||||||
|
>
|
||||||
|
<i class="bi bi-list-check"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/forge"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
title="Voir le contenu du dépôt lié"
|
||||||
|
>
|
||||||
|
<i class="bi bi-git"></i>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-success"
|
||||||
|
title="Relancer la notation"
|
||||||
|
on:click={() => { grade_idx[gsi].redoGradation(); gradationStatus[gsi] = null; }}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
39
ui/src/lib/components/WorkHeader.svelte
Normal file
39
ui/src/lib/components/WorkHeader.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script>
|
||||||
|
import { user } from '$lib/stores/user';
|
||||||
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
|
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
|
||||||
|
|
||||||
|
export let work;
|
||||||
|
export let my_submission;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dl style="columns: 3">
|
||||||
|
<dt>Date de début</dt>
|
||||||
|
<dd><DateFormat date={new Date(work.start_availability)} dateStyle="medium" timeStyle="medium" /></dd>
|
||||||
|
<dt>Date de fin</dt>
|
||||||
|
<dd><DateFormat date={new Date(work.end_availability)} dateStyle="medium" timeStyle="medium" /></dd>
|
||||||
|
{#if work.submission_url != "-"}
|
||||||
|
<dt>Rendu ?</dt>
|
||||||
|
<dd>
|
||||||
|
{#if work.submission_url}
|
||||||
|
<SubmissionStatus work={w} user={$user} />
|
||||||
|
{:else}
|
||||||
|
{#await my_submission}
|
||||||
|
<div class="spinner-grow spinner-grow-sm mx-1" role="status"></div>
|
||||||
|
{:then submission}
|
||||||
|
<i
|
||||||
|
class="bi bi-check-circle text-success"
|
||||||
|
title="Oui !"
|
||||||
|
></i>
|
||||||
|
<DateFormat date={new Date(submission.date)} dateStyle="medium" timeStyle="medium" />
|
||||||
|
{:catch}
|
||||||
|
<i
|
||||||
|
class="bi bi-x-circle text-danger"
|
||||||
|
title="Pas de rendu trouvé"
|
||||||
|
></i>
|
||||||
|
Non
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import BuildState from '../components/BuildState.svelte';
|
import BuildState from '$lib/components/BuildState.svelte';
|
||||||
import DateFormat from '../components/DateFormat.svelte';
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
import { WorkRepository, getRemoteRepositories, getRepositories } from '../lib/repositories';
|
import { WorkRepository, getRemoteRepositories, getRepositories } from '$lib/repositories';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|
@ -159,12 +159,12 @@
|
||||||
<span>Récupération de vos dépôts GitLab …</span>
|
<span>Récupération de vos dépôts GitLab …</span>
|
||||||
</div>
|
</div>
|
||||||
{:then rrepos}
|
{:then rrepos}
|
||||||
<select class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
|
<select id="repolist" class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
|
||||||
{#each rrepos as r (r.Id)}
|
{#each rrepos as r (r.ssh_url_to_repo)}
|
||||||
<option value={r.ssh_url_to_repo}>{r.path_with_namespace}</option>
|
<option value={r.ssh_url_to_repo}>{r.path_with_namespace}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<label>Dépôt GitLab pour ce travail :</label>
|
<label for="repolist">Dépôt GitLab pour ce travail :</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-2 btn btn-primary"
|
class="mt-2 btn btn-primary"
|
||||||
|
|
@ -185,6 +185,52 @@
|
||||||
>
|
>
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 btn btn-light"
|
||||||
|
on:click={() => {repo_used.uri = ""; repo_used.modal = new bootstrap.Modal(document.getElementById('customRepoModal')); repo_used.modal.show();}}
|
||||||
|
disable={submitInProgress || readonly || !repo_used || !repo_used.uri}
|
||||||
|
>
|
||||||
|
Utiliser un autre dépôt
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
|
|
||||||
|
{#if repo_used}
|
||||||
|
<div class="modal fade" tabindex="-1" id="customRepoModal">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form class="modal-content" on:submit|preventDefault={() => {repo_used.modal.hide(); delete repo_used.modal; submitWorkRepository()}}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Sélection de dépôt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>
|
||||||
|
Vous pouvez utiliser un dépôt hébergé sur une
|
||||||
|
autre forge, qu'elle soit publique ou
|
||||||
|
personnelle. Recopiez pour cela l'adresse du dépôt
|
||||||
|
dans le champ ci-dessous.
|
||||||
|
</p>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label" for="repo-address">Adresse du dépôt</label>
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
|
<input class="form-control" id="repo-address" autofocus placeholder="git@git.mydomain.net:path/to/repo.git" bind:value={repo_used.uri}>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Assurez-vous bien que votre dépôt <strong>n'est pas public</strong>
|
||||||
|
et d'avoir <strong>ajouté une clef de déploiement</strong>
|
||||||
|
à votre dépôt :
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINfzEnTiqwC4EeUG5EqfO0mLCygLU0HDiHTYgroNwjtT</pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Définir l'adresse de mon dépôt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
19
ui/src/lib/gradation.js
Normal file
19
ui/src/lib/gradation.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export async function getGradationRepositories() {
|
||||||
|
let url = '/api/gradation_repositories';
|
||||||
|
const res = await fetch(url, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGradationRepositories() {
|
||||||
|
let url = '/api/gradation_repositories/sync';
|
||||||
|
const res = await fetch(url, {method: 'post', headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
ui/src/lib/grades.js
Normal file
70
ui/src/lib/grades.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
export class Grade {
|
||||||
|
constructor(res) {
|
||||||
|
if (res) {
|
||||||
|
this.update(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ id, login, id_user, id_work, date, score, comment }) {
|
||||||
|
this.id = id;
|
||||||
|
this.login = login;
|
||||||
|
this.id_user = id_user;
|
||||||
|
this.id_work = id_work;
|
||||||
|
this.date = date;
|
||||||
|
this.score = score;
|
||||||
|
this.comment = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const res = await fetch(`api/works/${this.id_work}/grades/${this.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
body: JSON.stringify(this),
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json()
|
||||||
|
this.update(data);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
if (this.id) {
|
||||||
|
const res = await fetch(`api/works/${this.id_work}/grades/${this.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async redoGradation() {
|
||||||
|
const res = await fetch(this.id_user?`api/users/${this.id_user}/works/${this.id_work}/grades/${this.id}/traces`:`api/works/${this.id_work}/grades/${this.id}/traces`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async gradationStatus() {
|
||||||
|
const res = await fetch(this.id_user?`api/users/${this.id_user}/works/${this.id_work}/grades/${this.id}/status`:`api/works/${this.id_work}/grades/${this.id}/status`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,8 +62,10 @@ export class Question {
|
||||||
this.kind = kind;
|
this.kind = kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProposals() {
|
async getProposals(secret) {
|
||||||
const res = await fetch(`api/questions/${this.id}/proposals`, {
|
let url = `/questions/${this.id}/proposals`;
|
||||||
|
if (secret) url = `/s/surveys/${this.id_survey}` + url + `?secret=${secret}`;
|
||||||
|
const res = await fetch('api' + url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {'Accept': 'application/json'},
|
headers: {'Accept': 'application/json'},
|
||||||
});
|
});
|
||||||
|
|
@ -91,8 +93,10 @@ export class Question {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getResponses() {
|
async getResponses(secret) {
|
||||||
const res = await fetch(`api/surveys/${this.id_survey}/questions/${this.id}/responses`, {
|
let url = `/surveys/${this.id_survey}/questions/${this.id}/responses`;
|
||||||
|
if (secret) url = `/s` + url + `?secret=${secret}`;
|
||||||
|
const res = await fetch('api' + url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {'Accept': 'application/json'},
|
headers: {'Accept': 'application/json'},
|
||||||
});
|
});
|
||||||
|
|
@ -161,3 +165,17 @@ export async function getQuestions(sid) {
|
||||||
throw new Error((await res.json()).errmsg);
|
throw new Error((await res.json()).errmsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSharedQuestions(sid, secret) {
|
||||||
|
const res = await fetch(`api/s/surveys/${sid}/questions?secret=${secret}`, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data === null) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return (data).map((q) => new Question(q))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,14 @@ export class WorkRepository {
|
||||||
this.already_used = already_used == true;
|
this.already_used = already_used == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete() {
|
async delete(userid) {
|
||||||
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}`:`api/repositories/${this.id}`, {
|
let url = this.id_work?`works/${this.id_work}/repositories/${this.id}`:`repositories/${this.id}`;
|
||||||
|
|
||||||
|
if (userid) {
|
||||||
|
url = `users/${userid}/` + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("api/" + url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {'Accept': 'application/json'}
|
headers: {'Accept': 'application/json'}
|
||||||
});
|
});
|
||||||
|
|
@ -55,11 +61,11 @@ export class WorkRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveWork(tag) {
|
async retrieveWork(admin_struct) {
|
||||||
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/trigger`:`api/repositories/${this.id}/trigger`, {
|
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/trigger`:`api/repositories/${this.id}/trigger`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Accept': 'application/json'},
|
headers: {'Accept': 'application/json'},
|
||||||
body: !tag || tag.length == 0?null:JSON.stringify(tag)
|
body: !admin_struct?{}:JSON.stringify(admin_struct)
|
||||||
});
|
});
|
||||||
if (res.status == 200) {
|
if (res.status == 200) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -70,6 +76,30 @@ export class WorkRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runGradation() {
|
||||||
|
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/gradation`:`api/repositories/${this.id}/gradation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async gradationStatus() {
|
||||||
|
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/gradation_status`:`api/repositories/${this.id}/gradation_status`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async save(user) {
|
async save(user) {
|
||||||
let url = this.id?`repositories/${this.id}`:'repositories';
|
let url = this.id?`repositories/${this.id}`:'repositories';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,10 @@ function createUserStore() {
|
||||||
set: (auth) => {
|
set: (auth) => {
|
||||||
update((m) => auth);
|
update((m) => auth);
|
||||||
},
|
},
|
||||||
update: (res_auth, cb=null) => {
|
update: (res_auth) => {
|
||||||
if (res_auth.status === 200) {
|
if (res_auth.status === 200) {
|
||||||
res_auth.json().then((auth) => {
|
res_auth.json().then((auth) => {
|
||||||
update((m) => (Object.assign(m?m:{}, auth)));
|
update((m) => (Object.assign(m?m:{}, auth)));
|
||||||
|
|
||||||
if (cb) {
|
|
||||||
cb(my);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (res_auth.status >= 400 && res_auth.status < 500) {
|
} else if (res_auth.status >= 400 && res_auth.status < 500) {
|
||||||
update((m) => (null));
|
update((m) => (null));
|
||||||
|
|
@ -24,4 +20,14 @@ function createUserStore() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refresh_auth() {
|
||||||
|
const res = await fetch('api/auth', {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
const auth = await res.json();
|
||||||
|
user.set(auth);
|
||||||
|
} else {
|
||||||
|
user.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const user = createUserStore();
|
export const user = createUserStore();
|
||||||
|
|
@ -11,8 +11,9 @@ export class Survey {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update({ id, title, promo, group, shown, direct, corrected, start_availability, end_availability }) {
|
update({ id, id_category, title, promo, group, shown, direct, corrected, start_availability, end_availability }) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
this.id_category = id_category;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.promo = promo;
|
this.promo = promo;
|
||||||
this.group = group;
|
this.group = group;
|
||||||
|
|
@ -72,6 +73,18 @@ export class Survey {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async share() {
|
||||||
|
const res = await fetch(`api/surveys/${this.id}/shares`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Accept': 'application/json'}
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async save() {
|
async save() {
|
||||||
const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', {
|
const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', {
|
||||||
method: this.id?'PUT':'POST',
|
method: this.id?'PUT':'POST',
|
||||||
|
|
@ -104,31 +117,35 @@ export class Survey {
|
||||||
for (const q of questions) {
|
for (const q of questions) {
|
||||||
const oldQuestionId = q.id;
|
const oldQuestionId = q.id;
|
||||||
|
|
||||||
|
// This will create a new question with the same parameters
|
||||||
delete q.id;
|
delete q.id;
|
||||||
|
|
||||||
|
// Also alter id_survey
|
||||||
q.id_survey = response.id;
|
q.id_survey = response.id;
|
||||||
q.save().then((question) => {
|
|
||||||
q.id = oldQuestionId;
|
|
||||||
|
|
||||||
// Now recopy proposals
|
// This save will create
|
||||||
if (q.kind == "mcq" || q.kind == "ucq") {
|
const question = await q.save();
|
||||||
q.getProposals().then((proposals) => {
|
|
||||||
for (const p of proposals) {
|
// Revert to the old question ID to perform the next retrievals
|
||||||
delete p.id;
|
q.id = oldQuestionId;
|
||||||
p.id_question = question.id;
|
|
||||||
p.save();
|
// Now recopy proposals
|
||||||
}
|
if (q.kind == "mcq" || q.kind == "ucq") {
|
||||||
});
|
const proposals = await q.getProposals();
|
||||||
|
for (const p of proposals) {
|
||||||
|
delete p.id;
|
||||||
|
p.id_question = question.id;
|
||||||
|
await p.save();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Now recopy correction templates
|
// Now recopy correction templates
|
||||||
getCorrectionTemplates(oldQuestionId).then((cts) => {
|
const cts = await getCorrectionTemplates(oldQuestionId);
|
||||||
for (const ct of cts) {
|
for (const ct of cts) {
|
||||||
delete ct.id;
|
delete ct.id;
|
||||||
ct.id_question = question.id;
|
ct.id_question = question.id;
|
||||||
ct.save();
|
ct.save();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -185,3 +202,12 @@ export async function getSurvey(sid) {
|
||||||
throw new Error((await res.json()).errmsg);
|
throw new Error((await res.json()).errmsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSharedSurvey(sid, secret) {
|
||||||
|
const res = await fetch(`api/s/surveys/${sid}?secret=${secret}`, {headers: {'Accept': 'application/json'}})
|
||||||
|
if (res.status == 200) {
|
||||||
|
return new Survey(await res.json());
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export async function getUsers(promo, group) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function anonOldAccounts() {
|
||||||
|
const res = await fetch('api/users', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json()
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class User {
|
export class User {
|
||||||
constructor(res) {
|
constructor(res) {
|
||||||
if (res) {
|
if (res) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Grade } from '$lib/grades';
|
||||||
|
|
||||||
export class Work {
|
export class Work {
|
||||||
constructor(res) {
|
constructor(res) {
|
||||||
this.kind = "w";
|
this.kind = "w";
|
||||||
|
|
@ -6,8 +8,9 @@ export class Work {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update({ id, title, promo, group, shown, tag, description, descr_raw, submission_url, corrected, start_availability, end_availability }) {
|
update({ id, id_category, title, promo, group, shown, tag, description, descr_raw, submission_url, gradation_repo, corrected, start_availability, end_availability }) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
this.id_category = id_category;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.promo = promo;
|
this.promo = promo;
|
||||||
this.group = group;
|
this.group = group;
|
||||||
|
|
@ -16,6 +19,7 @@ export class Work {
|
||||||
this.description = description;
|
this.description = description;
|
||||||
this.descr_raw = descr_raw;
|
this.descr_raw = descr_raw;
|
||||||
this.submission_url = submission_url;
|
this.submission_url = submission_url;
|
||||||
|
this.gradation_repo = gradation_repo;
|
||||||
this.corrected = corrected;
|
this.corrected = corrected;
|
||||||
if (this.start_availability != start_availability) {
|
if (this.start_availability != start_availability) {
|
||||||
this.start_availability = start_availability;
|
this.start_availability = start_availability;
|
||||||
|
|
@ -91,6 +95,32 @@ export class Work {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopTests() {
|
||||||
|
if (this.id) {
|
||||||
|
const res = await fetch(`api/works/${this.id}/tests`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMissingGrades() {
|
||||||
|
const res = await fetch(`api/works/${this.id}/grades`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return (await res.json()).map((g) => new Grade(g));
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getSubmission(uid) {
|
async getSubmission(uid) {
|
||||||
const res = await fetch(uid?`api/users/${uid}/works/${this.id}/submission`:`api/works/${this.id}/submission`, {
|
const res = await fetch(uid?`api/users/${uid}/works/${this.id}/submission`:`api/works/${this.id}/submission`, {
|
||||||
headers: {'Accept': 'application/json'}
|
headers: {'Accept': 'application/json'}
|
||||||
|
|
@ -102,13 +132,25 @@ export class Work {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyTraces() {
|
||||||
|
const res = await fetch(`api/works/${this.id}/traces`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
});
|
||||||
|
if (res.status == 200) {
|
||||||
|
return await res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error((await res.json()).errmsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getGrades() {
|
async getGrades() {
|
||||||
const res = await fetch(`api/works/${this.id}/grades`, {
|
const res = await fetch(`api/works/${this.id}/grades`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {'Accept': 'application/json'},
|
headers: {'Accept': 'application/json'},
|
||||||
});
|
});
|
||||||
if (res.status == 200) {
|
if (res.status == 200) {
|
||||||
return await res.json();
|
return (await res.json()).map((g) => new Grade(g));
|
||||||
} else {
|
} else {
|
||||||
throw new Error((await res.json()).errmsg);
|
throw new Error((await res.json()).errmsg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
ui/src/routes/+layout.js
Normal file
16
ui/src/routes/+layout.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { refresh_auth, user } from '$lib/stores/user';
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
let refresh_interval_auth = null;
|
||||||
|
|
||||||
|
export async function load({ url }) {
|
||||||
|
refresh_interval_auth = setInterval(refresh_auth, Math.floor(Math.random() * 200000) + 200000);
|
||||||
|
refresh_auth();
|
||||||
|
|
||||||
|
const rroutes = url.pathname.split('/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
rroute: rroutes.length>1?rroutes[1]:'',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,51 +1,9 @@
|
||||||
<script context="module">
|
|
||||||
import { user } from '../stores/user';
|
|
||||||
let stop_refresh = false;
|
|
||||||
|
|
||||||
let refresh_interval_auth = null;
|
|
||||||
async function refresh_auth(cb=null, interval=null) {
|
|
||||||
if (refresh_interval_auth)
|
|
||||||
clearInterval(refresh_interval_auth);
|
|
||||||
if (interval === null) {
|
|
||||||
interval = Math.floor(Math.random() * 200000) + 200000;
|
|
||||||
}
|
|
||||||
if (stop_refresh) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refresh_interval_auth = setInterval(refresh_auth, interval);
|
|
||||||
|
|
||||||
const res = await fetch('api/auth', {headers: {'Accept': 'application/json'}})
|
|
||||||
if (res.status == 200) {
|
|
||||||
const auth = await res.json();
|
|
||||||
user.set(auth);
|
|
||||||
} else {
|
|
||||||
user.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load({ props, stuff, url }) {
|
|
||||||
refresh_auth();
|
|
||||||
|
|
||||||
const rroutes = url.pathname.split('/');
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
...props,
|
|
||||||
rroute: rroutes.length>1?rroutes[1]:'',
|
|
||||||
},
|
|
||||||
stuff: {
|
|
||||||
...stuff,
|
|
||||||
refresh_auth,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AuthButton from '../components/AuthButton.svelte';
|
import AuthButton from '$lib/components/AuthButton.svelte';
|
||||||
import Toaster from '../components/Toaster.svelte';
|
import Toaster from '$lib/components/Toaster.svelte';
|
||||||
|
import { refresh_auth, user } from '$lib/stores/user';
|
||||||
|
|
||||||
export let rroute = '';
|
export let data;
|
||||||
|
|
||||||
function switchAdminMode() {
|
function switchAdminMode() {
|
||||||
var tmp = $user.is_admin;
|
var tmp = $user.is_admin;
|
||||||
|
|
@ -71,6 +29,11 @@
|
||||||
<title>ЕРІТА: MCQ and others courses related stuff</title>
|
<title>ЕРІТА: MCQ and others courses related stuff</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if $user && $user.banner}
|
||||||
|
<div class="bg-danger text-white text-center py-1 fw-bolder">
|
||||||
|
{$user.banner}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if isSRS}
|
{#if isSRS}
|
||||||
<div style="position: fixed; bottom: 20px; right: 20px; z-index: -1; background-image: url('img/srstamps.png'); background-size: cover; width: 125px; height: 125px;"></div>
|
<div style="position: fixed; bottom: 20px; right: 20px; z-index: -1; background-image: url('img/srstamps.png'); background-size: cover; width: 125px; height: 125px;"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -86,7 +49,11 @@
|
||||||
<div class="collapse navbar-collapse" id="loggedMenu">
|
<div class="collapse navbar-collapse" id="loggedMenu">
|
||||||
<ul class="navbar-nav mr-auto">
|
<ul class="navbar-nav mr-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="adlin" target="_self">AdLin</a>
|
{#if $user && $user.promo != $user.current_promo}
|
||||||
|
<a class="nav-link" href="adlin/{$user.promo}" target="_self">AdLin</a>
|
||||||
|
{:else}
|
||||||
|
<a class="nav-link" href="adlin" target="_self">AdLin</a>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{#if isSRS}
|
{#if isSRS}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|
@ -94,19 +61,25 @@
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" class:active={rroute === 'surveys'} href="surveys">
|
<a class="nav-link" class:active={data.rroute === 'surveys'} href="surveys">
|
||||||
Questionnaires
|
Questionnaires
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" class:active={rroute === 'works'} href="works">
|
<a class="nav-link" class:active={data.rroute === 'works'} href="works">
|
||||||
Travaux
|
Travaux
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{#if $user && $user.is_admin}
|
{#if $user && $user.is_admin}
|
||||||
<li class="nav-item"><a class="nav-link" class:active={rroute === 'users'} href="users">Étudiants</a></li>
|
<li class="nav-item"><a class="nav-link" class:active={data.rroute === 'users'} href="users">Étudiants</a></li>
|
||||||
{/if}
|
{/if}
|
||||||
<li class="nav-item"><a class="nav-link" href="virli" target="_self">VIRLI</a></li>
|
<li class="nav-item">
|
||||||
|
{#if $user && $user.promo != $user.current_promo}
|
||||||
|
<a class="nav-link" href="virli/{$user.promo}" target="_self">VIRLI</a>
|
||||||
|
{:else}
|
||||||
|
<a class="nav-link" href="virli" target="_self">VIRLI</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
|
|
@ -125,13 +98,12 @@
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">
|
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#nav" role="button" aria-expanded="false">
|
||||||
<img class="rounded-circle" src="//photos.cri.epita.fr/square/{$user.login}" alt="Menu" style="margin: -0.75em 0; max-height: 2.5em; border: 2px solid white;">
|
<img class="rounded-circle" src="//photos.cri.epita.fr/square/{$user.login}" alt="Menu" style="margin: -0.75em 0; max-height: 2.5em; border: 2px solid white;">
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" class:active={rroute === 'keys'} href="keys">Clef PGP</a></li>
|
<li><a class="dropdown-item" class:active={data.rroute === 'keys'} href="keys">Clef PGP</a></li>
|
||||||
<li><a class="dropdown-item" class:active={rroute === 'help'} href="help">Besoin d'aide ?</a></li>
|
<li><a class="dropdown-item" class:active={data.rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
|
||||||
<li><a class="dropdown-item" class:active={rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item" on:click={disconnectCurrentUser}>
|
<button class="dropdown-item" on:click={disconnectCurrentUser}>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { getUser, getUserNeedingHelp } from '../lib/users';
|
import { getUser, getUserNeedingHelp } from '$lib/users';
|
||||||
import DateFormat from '../components/DateFormat.svelte';
|
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||||
import SurveyList from '../components/SurveyList.svelte';
|
import SurveyList from '$lib/components/SurveyList.svelte';
|
||||||
import ValidateSubmissions from '../components/ValidateSubmissions.svelte';
|
import ValidateSubmissions from '$lib/components/ValidateSubmissions.svelte';
|
||||||
|
|
||||||
let direct = null;
|
let direct = null;
|
||||||
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
import AuthButton from '../components/AuthButton.svelte';
|
import AuthButton from '$lib/components/AuthButton.svelte';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
|
|
||||||
let auth = { username: "", password: "" };
|
let auth = { username: "", password: "" };
|
||||||
let pleaseWait = false;
|
let pleaseWait = false;
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
<form class="col" on:submit|preventDefault={logmein}>
|
<form class="col" on:submit|preventDefault={logmein}>
|
||||||
<h2>Accès à votre compte</h2>
|
<h2>Accès à votre compte</h2>
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
<input type="text" class="form-control" id="login" bind:value={auth.username} placeholder="xavier.login" autofocus>
|
<input type="text" class="form-control" id="login" bind:value={auth.username} placeholder="xavier.login" autofocus>
|
||||||
<label for="login">CRI login</label>
|
<label for="login">CRI login</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,12 +59,28 @@
|
||||||
<div class="alert alert-warning d-flex">
|
<div class="alert alert-warning d-flex">
|
||||||
<i class="bi bi-exclamation-triangle me-3"></i>
|
<i class="bi bi-exclamation-triangle me-3"></i>
|
||||||
<span>
|
<span>
|
||||||
À toute fin utile, l'usage et la non-divulgation d'une vulnérabilité sont <a href="https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006149839/" target="_blank">sanctionnables</a>.
|
À toute fin utile, l'usage et la non-divulgation d'une vulnérabilité sont <a href="https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006149839/" target="_blank" rel="noreferrer">sanctionnables</a>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mt-5 mb-3">Hall of Fame</h3>
|
<h3 class="mt-5 mb-3">Hall of Fame</h3>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
L'accès aux questionnaires n'était pas filtré selon les groupes ou les promos.
|
||||||
|
<span class="badge bg-success shadow-lg">+2 pts</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row row-cols-6">
|
||||||
|
<img class="img-thumbnail" src="//photos.cri.epita.fr/francois.dautreme" alt="francois.dautreme">
|
||||||
|
</div>
|
||||||
|
<p class="card-text mt-3">
|
||||||
|
Divulguée et corrigée le 19 novembre 2022.
|
||||||
|
<a href="https://git.nemunai.re/teach/atsebay.t/commit/f675047ce8f6636aa45336b56c069172330b050f" target="_blank" rel="noreferrer">Commit</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
Il était toujours possible de répondre aux questionnaires après l'heure de clôture.
|
Il était toujours possible de répondre aux questionnaires après l'heure de clôture.
|
||||||
|
|
@ -81,7 +97,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="card-text mt-3">
|
<p class="card-text mt-3">
|
||||||
Divulguée et corrigée le 19 novembre 2021.
|
Divulguée et corrigée le 19 novembre 2021.
|
||||||
<a href="https://git.nemunai.re/srs/atsebay.t/commit/5c53d2eaea9e7233bc8a08de2f40c040c0700c3e" target="_blank">Commit</a>
|
<a href="https://git.nemunai.re/teach/atsebay.t/commit/5c53d2eaea9e7233bc8a08de2f40c040c0700c3e" target="_blank" rel="noreferrer">Commit</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
70
ui/src/routes/categories/+page.svelte
Normal file
70
ui/src/routes/categories/+page.svelte
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script>
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { user } from '$lib/stores/user';
|
||||||
|
import { getCategories } from '$lib/categories';
|
||||||
|
import { getPromos } from '$lib/users';
|
||||||
|
|
||||||
|
function showCategory(category) {
|
||||||
|
goto(`categories/${category.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterPromo = "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $user && $user.is_admin}
|
||||||
|
<a href="categories/new" class="btn btn-primary ml-1 float-end" title="Ajouter une catégorie">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</a>
|
||||||
|
{#await getPromos() then promos}
|
||||||
|
<div class="float-end me-2">
|
||||||
|
<select class="form-select" bind:value={filterPromo}>
|
||||||
|
<option value="">-</option>
|
||||||
|
{#each promos as promo, pid (pid)}
|
||||||
|
<option value={promo}>{promo}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
<h2>
|
||||||
|
Catégories // cours
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#await getCategories()}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-danger mx-3" role="status"></div>
|
||||||
|
<span>Chargement des catégories …</span>
|
||||||
|
</div>
|
||||||
|
{:then categories}
|
||||||
|
<table class="table table-sm table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Promo</th>
|
||||||
|
<th>Étendre</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each categories.filter((c) => (filterPromo === "" || filterPromo === c.promo)) as c (c.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{c.id}</td>
|
||||||
|
<td>
|
||||||
|
<a href="categories/{c.id}">{c.label}</a>
|
||||||
|
</td>
|
||||||
|
<td>{c.promo}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
class:bg-success={c.expand}
|
||||||
|
class:bg-danger={!c.expand}
|
||||||
|
>
|
||||||
|
{c.expand?"Oui":"Non"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/await}
|
||||||
5
ui/src/routes/categories/[cid]/+page.js
Normal file
5
ui/src/routes/categories/[cid]/+page.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export async function load({ params }) {
|
||||||
|
return {
|
||||||
|
cid: params.cid,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
ui/src/routes/categories/[cid]/+page.svelte
Normal file
25
ui/src/routes/categories/[cid]/+page.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script>
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { user } from '$lib/stores/user';
|
||||||
|
import CategoryAdmin from '$lib/components/CategoryAdmin.svelte';
|
||||||
|
import { Category, getCategory } from '$lib/categories';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let categoryP = null;
|
||||||
|
$: categoryP = getCategory(data.cid);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await categoryP then category}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h2>
|
||||||
|
<a href="categories/" class="text-muted" style="text-decoration: none"><</a>
|
||||||
|
{category.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $user && $user.is_admin}
|
||||||
|
<CategoryAdmin {category} on:saved={(e) => { goto(`categories/`)}} />
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
20
ui/src/routes/categories/new/+page.svelte
Normal file
20
ui/src/routes/categories/new/+page.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script>
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { user } from '$lib/stores/user';
|
||||||
|
import CategoryAdmin from '$lib/components/CategoryAdmin.svelte';
|
||||||
|
import { Category } from '$lib/categories';
|
||||||
|
|
||||||
|
let category = new Category();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h2>
|
||||||
|
<a href="categories/" class="text-muted" style="text-decoration: none"><</a>
|
||||||
|
Nouvelle catégorie
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $user && $user.is_admin}
|
||||||
|
<CategoryAdmin {category} on:saved={(e) => { goto(`categories/${e.detail.id}`)}} />
|
||||||
|
{/if}
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
<h3>Mesure d'audience</h3>
|
<h3>Mesure d'audience</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Le site <code>lessons.nemunai.re</code> utilise un outil de mesure d'audience : <a href="https://umami.is" target="_blank">Umami</a>.
|
Le site <code>lessons.nemunai.re</code> utilise un outil de mesure d'audience : <a href="https://umami.is" target="_blank" rel="noreferrer">Umami</a>.
|
||||||
Cet outil collecte des informations sur les pages visitées en anonymisant les données personnelles (l'IP notamment).
|
Cet outil collecte des informations sur les pages visitées en anonymisant les données personnelles (l'IP notamment).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
5
ui/src/routes/grades/+page.svelte
Normal file
5
ui/src/routes/grades/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import StudentGrades from '$lib/components/StudentGrades.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StudentGrades />
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<script context="module">
|
|
||||||
export async function load({ params }) {
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
promo: params.promo,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import StudentGrades from '../../components/StudentGrades.svelte';
|
|
||||||
|
|
||||||
export let promo;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<StudentGrades {promo} />
|
|
||||||
5
ui/src/routes/grades/[promo]/+layout.js
Normal file
5
ui/src/routes/grades/[promo]/+layout.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export async function load({ params }) {
|
||||||
|
return {
|
||||||
|
promo: parseInt(params.promo),
|
||||||
|
};
|
||||||
|
}
|
||||||
7
ui/src/routes/grades/[promo]/+page.svelte
Normal file
7
ui/src/routes/grades/[promo]/+page.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script>
|
||||||
|
import StudentGrades from '$lib/components/StudentGrades.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StudentGrades promo={data.promo} />
|
||||||
6
ui/src/routes/grades/[promo]/[cid]/+page.js
Normal file
6
ui/src/routes/grades/[promo]/[cid]/+page.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export async function load({ params }) {
|
||||||
|
return {
|
||||||
|
promo: parseInt(params.promo),
|
||||||
|
cid: parseInt(params.cid),
|
||||||
|
};
|
||||||
|
}
|
||||||
7
ui/src/routes/grades/[promo]/[cid]/+page.svelte
Normal file
7
ui/src/routes/grades/[promo]/[cid]/+page.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script>
|
||||||
|
import StudentGrades from '$lib/components/StudentGrades.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StudentGrades promo={data.promo} category={data.cid} />
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import StudentGrades from '../../components/StudentGrades.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<StudentGrades />
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
function needhelp() {
|
function needhelp() {
|
||||||
fetch('api/help', {
|
fetch('api/help', {
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<p>
|
<p>
|
||||||
Si tu souhaites me parler d'une situation qui t'a troublé·e, d'un problème que tu rencontres ou me faire une remarque,
|
Si tu souhaites me parler d'une situation qui t'a troublé·e, d'un problème que tu rencontres ou me faire une remarque,
|
||||||
n'hésite pas à venir me voir lors d'un cours, par exemple à la pause ou à la fin{#if $user} ;
|
n'hésite pas à venir me voir lors d'un cours, par exemple à la pause ou à la fin{#if $user} ;
|
||||||
je suis aussi joignable <a href="mailto:nemunaire@nemunai.re" class="umami--click--need-help-mail">par e-mail</a> ou bien <a href="https://matrix.to/#/@nemunaire:nemunai.re" class="umami--click--need-help-matrix">sur Matrix</a> ou Teams{/if}.
|
je suis aussi joignable <a href="mailto:nemunaire@nemunai.re" data-umami-event="need-help-mail">par e-mail</a> ou bien <a href="https://matrix.to/#/@nemunaire:nemunai.re" data-umami-event="need-help-matrix">sur Matrix</a> ou Teams{/if}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if $user}
|
{#if $user}
|
||||||
|
|
@ -52,7 +52,8 @@
|
||||||
Si tu souhaites juste avoir un peu plus d'attention, soit parce que tu te sens à l'écart, en difficulté ou autre :
|
Si tu souhaites juste avoir un peu plus d'attention, soit parce que tu te sens à l'écart, en difficulté ou autre :
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-primary umami--click--need-help"
|
class="btn btn-sm btn-primary"
|
||||||
|
data-umami-event="need-help"
|
||||||
on:click={needhelp}
|
on:click={needhelp}
|
||||||
>
|
>
|
||||||
Clique ce bouton
|
Clique ce bouton
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { deleteKey, getKeys, getKey, Key } from '../lib/key';
|
import { deleteKey, getKeys, getKey, Key } from '$lib/key';
|
||||||
import { user } from '../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { ToastsStore } from '../stores/toasts';
|
import { ToastsStore } from '$lib/stores/toasts';
|
||||||
|
|
||||||
let keysP = getKeys();
|
let keysP = getKeys();
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ ZWxxdWUgY2hvc2UK ...
|
||||||
Votre clef PGP peut vous servir à sécuriser les échanges de courriers électroniques. Par exemple les courriels officiels sur les listes de diffusion sensibles sont systématiquement signés (pour attester que c'est le responsable du projet qui en est bien à l'origine) ou encore si vous ne voulez pas que des tiers exploitent vos communications et/ou données personnelles (dans ce cas, on chiffre le contenu pour qu'il ne soit lisible que par le(s) destinataire(s) attendus).
|
Votre clef PGP peut vous servir à sécuriser les échanges de courriers électroniques. Par exemple les courriels officiels sur les listes de diffusion sensibles sont systématiquement signés (pour attester que c'est le responsable du projet qui en est bien à l'origine) ou encore si vous ne voulez pas que des tiers exploitent vos communications et/ou données personnelles (dans ce cas, on chiffre le contenu pour qu'il ne soit lisible que par le(s) destinataire(s) attendus).
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Avec git, vous pouvez signer chacun de <a href="https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/" target="_blank">vos commits</a>, ou <a href="https://dev.to/shostarsson/how-to-use-pgp-to-sign-your-commits-on-github-gitlab-bitbucket-3dae#fountainpen-sign-tags-using-your-gpg-key" target="_blank">vos tags</a>.<br>
|
Avec git, vous pouvez signer chacun de <a href="https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/" target="_blank" rel="noreferrer">vos commits</a>, ou <a href="https://dev.to/shostarsson/how-to-use-pgp-to-sign-your-commits-on-github-gitlab-bitbucket-3dae#fountainpen-sign-tags-using-your-gpg-key" target="_blank" rel="noreferrer">vos tags</a>.<br>
|
||||||
Si vous souhaitez contribuer au <a href="https://www.kernel.org/doc/html/latest/process/maintainer-pgp-guide.html">noyau Linux</a> (ou tout autre projet d'envergure), il est nécessaire de signer vos contributions. Cela permet d'éviter <a href="https://www.theregister.com/2021/03/29/php_repository_infected/">un certain nombre d'attaques</a>.
|
Si vous souhaitez contribuer au <a href="https://www.kernel.org/doc/html/latest/process/maintainer-pgp-guide.html">noyau Linux</a> (ou tout autre projet d'envergure), il est nécessaire de signer vos contributions. Cela permet d'éviter <a href="https://www.theregister.com/2021/03/29/php_repository_infected/">un certain nombre d'attaques</a>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
7
ui/src/routes/results/+page.js
Normal file
7
ui/src/routes/results/+page.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export async function load({ url }) {
|
||||||
|
return {
|
||||||
|
secret: url.searchParams.get("secret"),
|
||||||
|
idsurvey: url.searchParams.get("survey"),
|
||||||
|
exportview_list: url.searchParams.get("graph_list")?false:true,
|
||||||
|
};
|
||||||
|
}
|
||||||
48
ui/src/routes/results/+page.svelte
Normal file
48
ui/src/routes/results/+page.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script>
|
||||||
|
import { getSharedQuestions } from '$lib/questions';
|
||||||
|
import { getSharedSurvey } from '$lib/surveys';
|
||||||
|
import CorrectionPieChart from '$lib/components/CorrectionPieChart.svelte';
|
||||||
|
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let surveyP = null;
|
||||||
|
$: surveyP = getSharedSurvey(data.idsurvey, data.secret);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await surveyP then survey}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h2>
|
||||||
|
{survey.title}
|
||||||
|
<small class="text-muted">Réponses</small>
|
||||||
|
</h2>
|
||||||
|
<SurveyBadge class="ms-2" {survey} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#await getSharedQuestions(survey.id, data.secret)}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||||
|
<span>Chargement des questions …</span>
|
||||||
|
</div>
|
||||||
|
{:then questions}
|
||||||
|
{#each questions as question (question.id)}
|
||||||
|
<h3>{question.title}</h3>
|
||||||
|
{#if question.kind == "text" || (data.exportview_list && question.kind.indexOf("list") == 0)}
|
||||||
|
{#await question.getResponses(data.secret) then responses}
|
||||||
|
{#each responses as response (response.id)}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text" style:white-space="pre-line">
|
||||||
|
{response.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
{:else}
|
||||||
|
<CorrectionPieChart {question} secret={data.secret} />
|
||||||
|
{/if}
|
||||||
|
<hr class="mb-3">
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
{/await}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { user } from '../../stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import SurveyList from '../../components/SurveyList.svelte';
|
import SurveyList from '$lib/components/SurveyList.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
9
ui/src/routes/surveys/[sid]/+layout.js
Normal file
9
ui/src/routes/surveys/[sid]/+layout.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { getSurvey } from '$lib/surveys';
|
||||||
|
|
||||||
|
export async function load({ params }) {
|
||||||
|
const survey = getSurvey(params.sid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
survey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,8 @@
|
||||||
<script context="module">
|
|
||||||
import { getSurvey } from '../../../lib/surveys';
|
|
||||||
|
|
||||||
export async function load({ params, stuff }) {
|
|
||||||
const survey = getSurvey(params.sid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
survey,
|
|
||||||
},
|
|
||||||
stuff: {
|
|
||||||
...stuff,
|
|
||||||
survey,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
export let data;
|
||||||
export let survey;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await survey}
|
{#await data.survey}
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||||
<span>Chargement du questionnaire …</span>
|
<span>Chargement du questionnaire …</span>
|
||||||
5
ui/src/routes/surveys/[sid]/+page.js
Normal file
5
ui/src/routes/surveys/[sid]/+page.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export async function load({ parent }) {
|
||||||
|
const stuff = await parent();
|
||||||
|
|
||||||
|
return stuff;
|
||||||
|
}
|
||||||
60
ui/src/routes/surveys/[sid]/+page.svelte
Normal file
60
ui/src/routes/surveys/[sid]/+page.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { user } from '$lib/stores/user';
|
||||||
|
import SurveyAdmin from '$lib/components/SurveyAdmin.svelte';
|
||||||
|
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
|
||||||
|
import SurveyQuestions from '$lib/components/SurveyQuestions.svelte';
|
||||||
|
import { getQuestions } from '$lib/questions';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
let survey = null;
|
||||||
|
|
||||||
|
$: survey = data.survey;
|
||||||
|
$: if (survey.direct && !$user.is_admin) goto(`surveys/${survey.id}/live`);
|
||||||
|
|
||||||
|
let edit = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $user && $user.is_admin}
|
||||||
|
<button class="btn btn-primary ms-1 float-end" on:click={() => { edit = !edit; } } title="Éditer"><i class="bi bi-pencil"></i></button>
|
||||||
|
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
|
||||||
|
{#if survey.direct}
|
||||||
|
<a href="surveys/{survey.id}/live" class="btn btn-danger ms-1 float-end" title="Aller au direct"><i class="bi bi-film"></i></a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h2>
|
||||||
|
<a href="surveys/" class="text-muted" style="text-decoration: none"><</a>
|
||||||
|
{survey.title}
|
||||||
|
</h2>
|
||||||
|
<SurveyBadge class="ms-2" {survey} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $user && $user.is_admin && edit}
|
||||||
|
<SurveyAdmin {survey} on:saved={() => edit = false} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#await getQuestions(survey.id)}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||||
|
<span>Chargement des questions …</span>
|
||||||
|
</div>
|
||||||
|
{:then questions}
|
||||||
|
<SurveyQuestions {survey} {questions} />
|
||||||
|
{:catch error}
|
||||||
|
<div class="row mt-5">
|
||||||
|
<div class="d-none d-sm-block col-sm">
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
<h3 class="col-sm-auto text-center text-muted mb-3"><label for="askquestion">Ce questionnaire n'est pas accessible</label></h3>
|
||||||
|
<div class="d-none d-sm-block col-sm">
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if survey.direct != null}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong><a href="surveys/{survey.id}/live">Cliquez ici pour accéder au direct</a>.</strong> Il s'agit d'un questionnaire en direct, le questionnaire n'est pas accessible sur cette page.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
@ -1,704 +0,0 @@
|
||||||
<script context="module">
|
|
||||||
export async function load({ params, stuff }) {
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
surveyP: stuff.survey,
|
|
||||||
sid: params.sid,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { user } from '../../../stores/user';
|
|
||||||
import CorrectionPieChart from '../../../components/CorrectionPieChart.svelte';
|
|
||||||
import ListInputResponses from '../../../components/ListInputResponses.svelte';
|
|
||||||
import QuestionForm from '../../../components/QuestionForm.svelte';
|
|
||||||
import StartStopLiveSurvey from '../../../components/StartStopLiveSurvey.svelte';
|
|
||||||
import SurveyAdmin from '../../../components/SurveyAdmin.svelte';
|
|
||||||
import SurveyBadge from '../../../components/SurveyBadge.svelte';
|
|
||||||
import { getSurvey } from '../../../lib/surveys';
|
|
||||||
import { getQuestion, getQuestions, Question } from '../../../lib/questions';
|
|
||||||
import { getUsers } from '../../../lib/users';
|
|
||||||
|
|
||||||
export let surveyP;
|
|
||||||
export let sid;
|
|
||||||
let survey;
|
|
||||||
let req_questions;
|
|
||||||
|
|
||||||
surveyP.then((s) => {
|
|
||||||
survey = s;
|
|
||||||
updateQuestions();
|
|
||||||
if (survey.direct !== null) {
|
|
||||||
wsconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateSurvey() {
|
|
||||||
surveyP = getSurvey(survey.id);
|
|
||||||
surveyP.then((s) => {
|
|
||||||
survey = s;
|
|
||||||
updateQuestions();
|
|
||||||
if (survey.direct !== null) {
|
|
||||||
wsconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQuestions() {
|
|
||||||
req_questions = getQuestions(survey.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteQuestion(question) {
|
|
||||||
edit_question = null;
|
|
||||||
question.delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
let ws = null;
|
|
||||||
let ws_up = false;
|
|
||||||
let wsstats = null;
|
|
||||||
let current_question = null;
|
|
||||||
let edit_question = null;
|
|
||||||
let responses = {};
|
|
||||||
let corrected = false;
|
|
||||||
let next_corrected = false;
|
|
||||||
let timer = 20;
|
|
||||||
let timer_end = null;
|
|
||||||
let timer_remain = 0;
|
|
||||||
let timer_cancel = null;
|
|
||||||
|
|
||||||
function updTimer() {
|
|
||||||
const now = new Date().getTime();
|
|
||||||
if (now > timer_end) {
|
|
||||||
timer_remain = 0;
|
|
||||||
clearInterval(timer_cancel);
|
|
||||||
timer_cancel = null;
|
|
||||||
} else {
|
|
||||||
timer_remain = Math.floor((timer_end - now) / 100)/10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let users = {};
|
|
||||||
function updateUsers() {
|
|
||||||
getUsers().then((usr) => {
|
|
||||||
const tmp = { };
|
|
||||||
for (const u of usr) {
|
|
||||||
tmp[u.id.toString()] = u;
|
|
||||||
}
|
|
||||||
users = tmp;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateUsers();
|
|
||||||
|
|
||||||
let scroll_states = { };
|
|
||||||
let scroll_mean = 0;
|
|
||||||
$: {
|
|
||||||
let mean = 0;
|
|
||||||
for (const k in scroll_states) {
|
|
||||||
mean += scroll_states[k];
|
|
||||||
}
|
|
||||||
scroll_mean = mean / Object.keys(scroll_states).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let responsesbyid = { };
|
|
||||||
$: {
|
|
||||||
const tmp = { };
|
|
||||||
for (const response in responses) {
|
|
||||||
if (!tmp[response]) tmp[response] = [];
|
|
||||||
for (const r in responses[response]) {
|
|
||||||
tmp[response].push(responses[response][r]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responsesbyid = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
let graph_data = {labels:[], datasets:[]};
|
|
||||||
async function reset_graph_data(questionid) {
|
|
||||||
if (questionid) {
|
|
||||||
const labels = [];
|
|
||||||
const flabels = [];
|
|
||||||
|
|
||||||
let question = null;
|
|
||||||
for (const q of await req_questions) {
|
|
||||||
if (q.id == current_question) {
|
|
||||||
question = q;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (question) {
|
|
||||||
for (const p of await question.getProposals()) {
|
|
||||||
flabels.push(p.id.toString());
|
|
||||||
labels.push(p.label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
graph_data = {
|
|
||||||
labels,
|
|
||||||
flabels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
values: labels.map(() => 0)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current_question && responses[current_question] && graph_data.labels.length != 0) {
|
|
||||||
const values = graph_data.datasets[0].values.map(() => 0);
|
|
||||||
|
|
||||||
for (const u in responses[current_question]) {
|
|
||||||
const res = responses[current_question][u];
|
|
||||||
for (const r of res.split(',')) {
|
|
||||||
let idx = graph_data.flabels.indexOf(r);
|
|
||||||
values[idx] += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
graph_data.datasets[0].values = values;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let asks = [];
|
|
||||||
function wsconnect() {
|
|
||||||
if (ws !== null) return;
|
|
||||||
|
|
||||||
ws = new WebSocket((window.location.protocol == 'https:'?'wss://':'ws://') + window.location.host + `/api/surveys/${sid}/ws-admin`);
|
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
|
||||||
ws_up = true;
|
|
||||||
ws.send('{"action":"get_responses"}');
|
|
||||||
ws.send('{"action":"get_stats"}');
|
|
||||||
ws.send('{"action":"get_asks"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener("close", (e) => {
|
|
||||||
ws_up = false;
|
|
||||||
console.log('Socket is closed. Reconnect will be attempted in 1 second.');
|
|
||||||
setTimeout(function() {
|
|
||||||
ws = null;
|
|
||||||
updateSurvey();
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener("error", (err) => {
|
|
||||||
ws_up = false;
|
|
||||||
console.log('Socket closed due to error.', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener("message", (message) => {
|
|
||||||
const data = JSON.parse(message.data);
|
|
||||||
if (data.action && data.action == "new_question") {
|
|
||||||
current_question = data.question;
|
|
||||||
corrected = data.corrected == true;
|
|
||||||
if (timer_cancel) {
|
|
||||||
clearInterval(timer_cancel);
|
|
||||||
timer_cancel = null;
|
|
||||||
}
|
|
||||||
if (data.timer) {
|
|
||||||
timer_end = new Date().getTime() + data.timer;
|
|
||||||
timer_cancel = setInterval(updTimer, 250);
|
|
||||||
} else {
|
|
||||||
timer_end = null;
|
|
||||||
}
|
|
||||||
reset_graph_data(data.question);
|
|
||||||
} else if (data.action && data.action == "stats") {
|
|
||||||
wsstats = data.stats;
|
|
||||||
} else if (data.action && data.action == "new_response") {
|
|
||||||
if (!responses[data.question]) responses[data.question] = { };
|
|
||||||
responses[data.question][data.user] = data.value;
|
|
||||||
|
|
||||||
reset_graph_data();
|
|
||||||
} else if (data.action && data.action == "new_ask") {
|
|
||||||
asks.push({"id": data.question, "content": data.value, "userid": data.user});
|
|
||||||
asks = asks;
|
|
||||||
} else if (data.action && data.action == "myscroll" && wsstats && wsstats.users) {
|
|
||||||
scroll_states[data.user] = parseFloat(data.value);
|
|
||||||
for (const k in wsstats.users) {
|
|
||||||
if (wsstats.users[k].id == data.user) {
|
|
||||||
wsstats.users[k].myscroll = scroll_states[data.user];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.action && data.action == "end") {
|
|
||||||
ws.close();
|
|
||||||
updateSurvey();
|
|
||||||
} else {
|
|
||||||
current_question = null;
|
|
||||||
timer_end = null;
|
|
||||||
if (timer_cancel) {
|
|
||||||
clearInterval(timer_cancel);
|
|
||||||
timer_cancel = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await surveyP then survey}
|
|
||||||
{#if $user && $user.is_admin}
|
|
||||||
<StartStopLiveSurvey
|
|
||||||
{survey}
|
|
||||||
class="ms-1 float-end"
|
|
||||||
on:update={() => updateSurvey()}
|
|
||||||
on:end={() => { if (confirm("Sûr ?")) ws.send('{"action":"end"}') }}
|
|
||||||
/>
|
|
||||||
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
|
|
||||||
{/if}
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<h2>
|
|
||||||
<a href="surveys/" class="text-muted" style="text-decoration: none"><</a>
|
|
||||||
{survey.title}
|
|
||||||
<small class="text-muted">
|
|
||||||
Administration
|
|
||||||
</small>
|
|
||||||
{#if asks.length}
|
|
||||||
<a href="surveys/{sid}/admin#questions_part">
|
|
||||||
<i class="bi bi-patch-question-fill text-danger"></i>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</h2>
|
|
||||||
{#if survey.direct !== null}
|
|
||||||
<div
|
|
||||||
class="badge rounded-pill ms-2"
|
|
||||||
class:bg-success={ws_up}
|
|
||||||
class:bg-danger={!ws_up}
|
|
||||||
>
|
|
||||||
{#if ws_up}Connecté{:else}Déconnecté{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<SurveyBadge
|
|
||||||
class="mx-2"
|
|
||||||
{survey}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if survey.direct === null}
|
|
||||||
<SurveyAdmin
|
|
||||||
{survey}
|
|
||||||
on:saved={updateSurvey}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{#await req_questions}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
|
||||||
<span>Chargement des questions …</span>
|
|
||||||
</div>
|
|
||||||
{:then questions}
|
|
||||||
<div class="card my-3">
|
|
||||||
<table class="table table-hover table-striped mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
Question
|
|
||||||
{#if timer_end}
|
|
||||||
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="form-control"
|
|
||||||
disabled
|
|
||||||
value={timer_remain}
|
|
||||||
>
|
|
||||||
<span class="input-group-text">s</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="form-control"
|
|
||||||
bind:value={timer}
|
|
||||||
placeholder="Valeur du timer"
|
|
||||||
>
|
|
||||||
<span class="input-group-text">s</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info ms-1"
|
|
||||||
on:click={updateQuestions}
|
|
||||||
title="Rafraîchir les questions"
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Réponses
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Actions
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
disabled={!current_question || !ws_up}
|
|
||||||
on:click={() => { ws.send('{"action":"pause"}')} }
|
|
||||||
title="Passer sur une scène sans question"
|
|
||||||
>
|
|
||||||
<i class="bi bi-pause-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
class:btn-outline-success={!next_corrected}
|
|
||||||
class:btn-success={next_corrected}
|
|
||||||
on:click={() => { next_corrected = !next_corrected } }
|
|
||||||
title="La prochaine question est affichée corrigée"
|
|
||||||
>
|
|
||||||
<i class="bi bi-eye"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info"
|
|
||||||
on:click={() => { edit_question = new Question({ id_survey: survey.id }) } }
|
|
||||||
title="Ajouter une question"
|
|
||||||
>
|
|
||||||
<i class="bi bi-plus"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
on:click={() => { fetch('api/cache', {method: 'DELETE'}) } }
|
|
||||||
title="Vider les caches"
|
|
||||||
>
|
|
||||||
<i class="bi bi-bandaid-fill"></i>
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each questions as question (question.id)}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{#if responses[question.id]}
|
|
||||||
<a href="surveys/{sid}/admin#q{question.id}_res">
|
|
||||||
{question.title}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
{question.title}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if responses[question.id]}
|
|
||||||
{Object.keys(responses[question.id]).length}
|
|
||||||
{:else}
|
|
||||||
0
|
|
||||||
{/if}
|
|
||||||
{#if wsstats}/ {wsstats.nb_clients}{/if}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
class:btn-primary={!next_corrected}
|
|
||||||
class:btn-success={next_corrected}
|
|
||||||
disabled={(question.id === current_question && next_corrected == corrected) || !ws_up}
|
|
||||||
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + ', "timer": 0, "question":' + question.id + '}')} }
|
|
||||||
>
|
|
||||||
<i class="bi bi-play-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-danger"
|
|
||||||
disabled={question.id === current_question || !ws_up}
|
|
||||||
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + ', "timer": ' + timer * 1000 + ',"question":' + question.id + '}')} }
|
|
||||||
>
|
|
||||||
<i class="bi bi-stopwatch-fill"></i>
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="/surveys/{survey.id}/responses/{question.id}"
|
|
||||||
target="_blank"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-success"
|
|
||||||
>
|
|
||||||
<i class="bi bi-files"></i>
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info"
|
|
||||||
disabled={question.id === current_question}
|
|
||||||
on:click={() => { getQuestion(question.id).then((q) => {edit_question = q})} }
|
|
||||||
>
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
||||||
{#if edit_question !== null}
|
|
||||||
<QuestionForm
|
|
||||||
{survey}
|
|
||||||
edit
|
|
||||||
question={edit_question}
|
|
||||||
on:delete={() => deleteQuestion(edit_question)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info ms-1 float-end"
|
|
||||||
on:click={() => { ws.send('{"action":"get_asks", "value": ""}'); asks = []; }}
|
|
||||||
title="Rafraîchir les réponses"
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
|
||||||
<i class="bi bi-question-diamond"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-light ms-1 float-end"
|
|
||||||
on:click={() => { ws.send('{"action":"get_asks", "value": "unanswered"}'); asks = []; }}
|
|
||||||
title="Rafraîchir les réponses, en rapportant les réponses déjà répondues"
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
|
||||||
<i class="bi bi-question-diamond"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-success float-end"
|
|
||||||
title="Tout marqué comme répondu"
|
|
||||||
on:click={() => { ws.send('{"action":"mark_answered", "value": "all"}'); asks = [] }}
|
|
||||||
>
|
|
||||||
<i class="bi bi-check-all"></i>
|
|
||||||
</button>
|
|
||||||
<h3 id="questions_part">
|
|
||||||
Questions
|
|
||||||
{#if asks.length}
|
|
||||||
<small class="text-muted">
|
|
||||||
{asks.length} question{#if asks.length > 1}s{/if}
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
{#if asks.length}
|
|
||||||
{#each asks as ask (ask.id)}
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="card-text">
|
|
||||||
{ask.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-success float-end"
|
|
||||||
title="Marqué comme répondu"
|
|
||||||
on:click={() => { ws.send('{"action":"mark_answered", "question": ' + ask.id + '}'); asks = asks.filter((e) => e.id != ask.id) }}
|
|
||||||
>
|
|
||||||
<i class="bi bi-check"></i>
|
|
||||||
</button>
|
|
||||||
Par
|
|
||||||
<a href="users/{ask.userid}" target="_blank">
|
|
||||||
{#if users && users[ask.userid]}
|
|
||||||
{users[ask.userid].login}
|
|
||||||
{:else}
|
|
||||||
{ask.userid}
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<div class="text-center text-muted">
|
|
||||||
Pas de question pour l'instant.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info ms-1 float-end"
|
|
||||||
on:click={() => { ws.send('{"action":"get_responses"}') }}
|
|
||||||
title="Rafraîchir les réponses"
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
|
||||||
<i class="bi bi-card-checklist"></i>
|
|
||||||
</button>
|
|
||||||
<h3>
|
|
||||||
Réponses
|
|
||||||
</h3>
|
|
||||||
{#if Object.keys(responses).length}
|
|
||||||
{#each Object.keys(responses) as q, qid (qid)}
|
|
||||||
{#await req_questions then questions}
|
|
||||||
{#each questions as question}
|
|
||||||
{#if question.id == q}
|
|
||||||
<h4 id="q{question.id}_res">
|
|
||||||
{question.title}
|
|
||||||
</h4>
|
|
||||||
{#if question.kind == 'ucq'}
|
|
||||||
{#await question.getProposals()}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
|
||||||
<span>Chargement des propositions …</span>
|
|
||||||
</div>
|
|
||||||
{:then proposals}
|
|
||||||
{#if current_question == question.id}
|
|
||||||
<CorrectionPieChart
|
|
||||||
{question}
|
|
||||||
{proposals}
|
|
||||||
data={graph_data}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<CorrectionPieChart
|
|
||||||
{question}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<table class="table table-sm table-striped table-hover mb-0">
|
|
||||||
<tbody>
|
|
||||||
{#each proposals as proposal (proposal.id)}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{proposal.label}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{responsesbyid[q].filter((e) => e == proposal.id.toString()).length}/{responsesbyid[q].length}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{Math.trunc(responsesbyid[q].filter((e) => e == proposal.id.toString()).length / responsesbyid[q].length * 1000)/10} %
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
||||||
{:else if question.kind == 'mcq'}
|
|
||||||
{#await question.getProposals()}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
|
||||||
<span>Chargement des propositions …</span>
|
|
||||||
</div>
|
|
||||||
{:then proposals}
|
|
||||||
{#if current_question == question.id}
|
|
||||||
<CorrectionPieChart
|
|
||||||
{question}
|
|
||||||
{proposals}
|
|
||||||
data={graph_data}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<CorrectionPieChart
|
|
||||||
{question}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<table class="table table-sm table-striped table-hover mb-0">
|
|
||||||
<tbody>
|
|
||||||
{#each proposals as proposal (proposal.id)}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{proposal.label}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length}/{responsesbyid[q].length}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{Math.trunc(responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length / responsesbyid[q].length * 1000)/10} %
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
||||||
{:else if question.kind && question.kind.startsWith('list')}
|
|
||||||
<ListInputResponses
|
|
||||||
responses={responses[q]}
|
|
||||||
{users}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
{#each Object.keys(responses[q]) as user, rid (rid)}
|
|
||||||
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
{responses[q][user]}
|
|
||||||
</span>
|
|
||||||
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
|
|
||||||
{#if users && users[user]}
|
|
||||||
{users[user].login}
|
|
||||||
{:else}
|
|
||||||
{user}
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/await}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info ms-1 float-end"
|
|
||||||
on:click={() => { ws.send('{"action":"get_stats"}') }}
|
|
||||||
title="Rafraîchir les stats"
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
|
||||||
<i class="bi bi-123"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary ms-1 float-end"
|
|
||||||
title="Rafraîchir la liste des utilisateurs"
|
|
||||||
on:click={updateUsers}
|
|
||||||
>
|
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-warning ms-1 float-end"
|
|
||||||
on:click={() => { scroll_states = {}; ws.send('{"action":"where_are_you"}')} }
|
|
||||||
title="Rapporter l'avancement"
|
|
||||||
>
|
|
||||||
<i class="bi bi-geo-fill"></i>
|
|
||||||
</button>
|
|
||||||
<h3 id="users">
|
|
||||||
Connectés
|
|
||||||
{#if wsstats}
|
|
||||||
<small class="text-muted">{wsstats.nb_clients} utilisateurs</small>
|
|
||||||
{/if}
|
|
||||||
{#if scroll_mean}
|
|
||||||
<small
|
|
||||||
class:text-danger={scroll_mean >= 0 && scroll_mean < 0.2}
|
|
||||||
class:text-warning={scroll_mean >= 0.2 && scroll_mean < 0.6}
|
|
||||||
class:text-info={scroll_mean >= 0.6 && scroll_mean < 0.9}
|
|
||||||
class:text-success={scroll_mean >= 0.9}
|
|
||||||
>Avancement global : {Math.trunc(scroll_mean*10000)/100} %</small>
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
{#if wsstats && wsstats.users}
|
|
||||||
<div class="row row-cols-5 py-3">
|
|
||||||
{#each wsstats.users as user, lid (lid)}
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<img alt="{user.login}" src="//photos.cri.epita.fr/thumb/{user.login}" class="card-img-top">
|
|
||||||
<div class="card-footer text-center text-truncate p-0">
|
|
||||||
<a href="users/{user.login}" target="_blank">
|
|
||||||
{user.login}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{#if user.myscroll != null}
|
|
||||||
<div
|
|
||||||
class="card-footer py-0 px-1"
|
|
||||||
class:bg-danger={user.myscroll >= 0 && user.myscroll < 0.2}
|
|
||||||
class:bg-warning={user.myscroll >= 0.2 && user.myscroll < 0.6}
|
|
||||||
class:bg-info={user.myscroll >= 0.6 && user.myscroll < 0.9}
|
|
||||||
class:bg-success={user.myscroll >= 0.9}
|
|
||||||
>
|
|
||||||
Avancement : {Math.trunc(user.myscroll*10000)/100} %
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{/await}
|
|
||||||
8
ui/src/routes/surveys/[sid]/admin/+page.js
Normal file
8
ui/src/routes/surveys/[sid]/admin/+page.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export async function load({ parent, params }) {
|
||||||
|
const stuff = await parent();
|
||||||
|
|
||||||
|
return {
|
||||||
|
survey: stuff.survey,
|
||||||
|
sid: params.sid,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue