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
|
||||
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
|
||||
|
||||
platform:
|
||||
|
|
@ -59,7 +9,7 @@ platform:
|
|||
|
||||
steps:
|
||||
- name: build front
|
||||
image: node:19-alpine
|
||||
image: node:21-alpine
|
||||
commands:
|
||||
- mkdir deploy
|
||||
- cd ui
|
||||
|
|
@ -119,5 +69,4 @@ trigger:
|
|||
- tag
|
||||
|
||||
depends_on:
|
||||
- build-arm
|
||||
- build-arm64
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:19-alpine as nodebuild
|
||||
FROM node:21-alpine as nodebuild
|
||||
|
||||
WORKDIR /ui
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ RUN go get -d -v && \
|
|||
go build -v -buildvcs=false -ldflags="-s -w" -o atsebay.t
|
||||
|
||||
|
||||
FROM alpine:3.16
|
||||
FROM alpine:3.19
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
|
|
|
|||
4
api.go
4
api.go
|
|
@ -13,9 +13,11 @@ func declareAPIRoutes(router *gin.Engine) {
|
|||
apiRoutes.Use(authMiddleware())
|
||||
|
||||
declareAPIAuthRoutes(apiRoutes)
|
||||
declareAPICategoriesRoutes(apiRoutes)
|
||||
declareAPISurveysRoutes(apiRoutes)
|
||||
declareAPIWorksRoutes(apiRoutes)
|
||||
declareAPIKeysRoutes(apiRoutes)
|
||||
declareAPISharesRoutes(apiRoutes)
|
||||
declareCallbacksRoutes(apiRoutes)
|
||||
|
||||
authRoutes := router.Group("")
|
||||
|
|
@ -50,7 +52,9 @@ func declareAPIRoutes(router *gin.Engine) {
|
|||
|
||||
declareAPIAdminAuthRoutes(apiAdminRoutes)
|
||||
declareAPIAdminAsksRoutes(apiAdminRoutes)
|
||||
declareAPIAdminCategoriesRoutes(apiRoutes)
|
||||
declareAPIAuthGradesRoutes(apiAdminRoutes)
|
||||
declareAPIAdminGradationRoutes(apiAdminRoutes)
|
||||
declareAPIAdminHelpRoutes(apiAdminRoutes)
|
||||
declareAPIAdminQuestionsRoutes(apiAdminRoutes)
|
||||
declareAPIAuthRepositoriesRoutes(apiAdminRoutes)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"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 http.FileSystem
|
||||
|
|
|
|||
29
auth.go
29
auth.go
|
|
@ -13,6 +13,7 @@ import (
|
|||
var LocalAuthFunc = checkAuthKrb5
|
||||
var allowLocalAuth bool
|
||||
var localAuthUsers arrayFlags
|
||||
var mainBanner string
|
||||
|
||||
type loginForm struct {
|
||||
Login string `json:"username"`
|
||||
|
|
@ -47,16 +48,18 @@ func declareAPIAdminAuthRoutes(router *gin.RouterGroup) {
|
|||
session.Update()
|
||||
|
||||
c.JSON(http.StatusOK, authToken{
|
||||
User: newuser,
|
||||
CurrentPromo: currentPromo,
|
||||
User: newuser,
|
||||
CurrentPromo: currentPromo,
|
||||
MessageBanner: mainBanner,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type authToken struct {
|
||||
*User
|
||||
CurrentPromo uint `json:"current_promo"`
|
||||
Groups []string `json:"groups"`
|
||||
CurrentPromo uint `json:"current_promo"`
|
||||
Groups []string `json:"groups"`
|
||||
MessageBanner string `json:"banner,omitempty"`
|
||||
}
|
||||
|
||||
func validateAuthToken(c *gin.Context) {
|
||||
|
|
@ -64,7 +67,7 @@ func validateAuthToken(c *gin.Context) {
|
|||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not connected"})
|
||||
return
|
||||
} 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) }), ",")
|
||||
|
||||
|
|
@ -77,7 +80,7 @@ func logout(c *gin.Context) {
|
|||
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 promo == 0 {
|
||||
promo = currentPromo
|
||||
|
|
@ -114,14 +117,10 @@ func completeAuth(c *gin.Context, username string, email string, firstname strin
|
|||
|
||||
if session == nil {
|
||||
session, err = usr.NewSession()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err = session.SetUser(usr)
|
||||
}
|
||||
if face_url != "" {
|
||||
session.SetKey("picture", face_url)
|
||||
}
|
||||
_, err = session.SetUser(usr)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -157,10 +156,10 @@ func dummyAuth(c *gin.Context) {
|
|||
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()})
|
||||
return
|
||||
} 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
|
||||
}
|
||||
|
||||
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()})
|
||||
return
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func initializeOIDC(router *gin.Engine) {
|
|||
Endpoint: provider.Endpoint(),
|
||||
|
||||
// "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{
|
||||
|
|
@ -112,9 +112,6 @@ func OIDC_CRI_complete(c *gin.Context) {
|
|||
Groups []map[string]interface{} `json:"groups"`
|
||||
Campuses []string `json:"campuses"`
|
||||
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 {
|
||||
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()})
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS surveys(
|
||||
id_survey INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
id_category INTEGER NOT NULL,
|
||||
title VARCHAR(255),
|
||||
promo MEDIUMINT NOT NULL,
|
||||
grp VARCHAR(255) NOT NULL,
|
||||
|
|
@ -100,7 +101,8 @@ CREATE TABLE IF NOT EXISTS surveys(
|
|||
direct INTEGER DEFAULT NULL,
|
||||
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
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;
|
||||
`); err != nil {
|
||||
return err
|
||||
|
|
@ -200,6 +202,7 @@ CREATE TABLE IF NOT EXISTS user_need_help(
|
|||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS works(
|
||||
id_work INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
id_category INTEGER NOT NULL,
|
||||
title VARCHAR(255),
|
||||
promo MEDIUMINT NOT NULL,
|
||||
grp VARCHAR(255) NOT NULL,
|
||||
|
|
@ -207,9 +210,11 @@ CREATE TABLE IF NOT EXISTS works(
|
|||
description TEXT NOT NULL,
|
||||
tag VARCHAR(255) NOT NULL,
|
||||
submission_URL VARCHAR(255) NULL,
|
||||
gradation_repo VARCHAR(255) NULL,
|
||||
corrected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
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;
|
||||
`); err != nil {
|
||||
return err
|
||||
|
|
@ -237,6 +242,8 @@ CREATE TABLE IF NOT EXISTS user_work_repositories(
|
|||
secret BLOB NOT NULL,
|
||||
last_check TIMESTAMP NULL DEFAULT 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_work) REFERENCES works(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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
91
direct.go
91
direct.go
|
|
@ -2,8 +2,10 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -280,6 +282,62 @@ func getCorrectionString(qid int64) (ret map[string]int) {
|
|||
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) {
|
||||
u := c.MustGet("LoggedUser").(*User)
|
||||
survey := c.MustGet("survey").(*Survey)
|
||||
|
|
@ -309,6 +367,7 @@ func SurveyWSAdmin(c *gin.Context) {
|
|||
go func(c chan WSMessage, sid int) {
|
||||
var v WSMessage
|
||||
var err error
|
||||
var surveyTimer *time.Timer
|
||||
for {
|
||||
// Reset variable state
|
||||
v.Corrected = false
|
||||
|
|
@ -326,19 +385,35 @@ func SurveyWSAdmin(c *gin.Context) {
|
|||
if survey, err := getSurvey(sid); err != nil {
|
||||
log.Println("Unable to retrieve survey:", err)
|
||||
} else {
|
||||
// Skip any existing scheduled timer
|
||||
if surveyTimer != nil {
|
||||
if !surveyTimer.Stop() {
|
||||
<-surveyTimer.C
|
||||
}
|
||||
surveyTimer = nil
|
||||
}
|
||||
|
||||
survey.Direct = v.QuestionId
|
||||
if v.Timer > 0 {
|
||||
survey.Corrected = false
|
||||
survey.Update()
|
||||
|
||||
go func(corrected bool) {
|
||||
time.Sleep(time.Duration(OffsetQuestionTimer+v.Timer) * time.Millisecond)
|
||||
// Save corrected state for the callback
|
||||
corrected := v.Corrected
|
||||
with_stats := v.Stats != nil
|
||||
|
||||
surveyTimer = time.AfterFunc(time.Duration(OffsetQuestionTimer+v.Timer)*time.Millisecond, func() {
|
||||
surveyTimer = nil
|
||||
if corrected {
|
||||
survey.Corrected = v.Corrected
|
||||
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 {
|
||||
var z int64 = 0
|
||||
survey.Direct = &z
|
||||
|
|
@ -347,12 +422,20 @@ func SurveyWSAdmin(c *gin.Context) {
|
|||
survey.WSWriteAll(WSMessage{Action: "pause"})
|
||||
WSAdminWriteAll(WSMessage{Action: "pause", SurveyId: &survey.Id})
|
||||
}
|
||||
}(v.Corrected)
|
||||
})
|
||||
v.Corrected = false
|
||||
v.Stats = nil
|
||||
} else {
|
||||
survey.Corrected = v.Corrected
|
||||
if v.Corrected {
|
||||
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()
|
||||
|
|
|
|||
57
gitlab.go
57
gitlab.go
|
|
@ -170,11 +170,13 @@ type GitLabUser struct {
|
|||
Username string
|
||||
Name string
|
||||
State string
|
||||
Email string
|
||||
}
|
||||
|
||||
type GitLabUserKey struct {
|
||||
ID int
|
||||
Key string
|
||||
ID int
|
||||
Key string
|
||||
UsageType string `json:"usage_type"`
|
||||
}
|
||||
|
||||
type GitLabRepository struct {
|
||||
|
|
@ -269,7 +271,7 @@ func GitLab_getUsersRepositories(c context.Context, u *User) ([]*GitLabRepositor
|
|||
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())
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
err = json.NewDecoder(resp.Body).Decode(&users)
|
||||
|
||||
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) {
|
||||
|
|
@ -334,3 +345,33 @@ func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
|
|||
|
||||
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
|
||||
|
||||
go 1.18
|
||||
go 1.21
|
||||
|
||||
toolchain go1.22.1
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4
|
||||
github.com/aws/aws-sdk-go v1.44.135
|
||||
github.com/coreos/go-oidc/v3 v3.4.0
|
||||
github.com/ProtonMail/go-crypto v1.0.0
|
||||
github.com/aws/aws-sdk-go v1.51.9
|
||||
github.com/coreos/go-oidc/v3 v3.10.0
|
||||
github.com/drone/drone-go v1.7.1
|
||||
github.com/gin-gonic/gin v1.8.1
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
golang.org/x/oauth2 v0.2.0
|
||||
nhooyr.io/websocket v1.8.7
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
nhooyr.io/websocket v1.8.10
|
||||
)
|
||||
|
||||
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/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.10.0 // indirect
|
||||
github.com/goccy/go-json v0.9.7 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // 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/jcmturner/aescts/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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.10.3 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // 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/pelletier/go-toml/v2 v2.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.7 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||
golang.org/x/net v0.2.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.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/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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -65,28 +67,44 @@ func declareAPIAuthGradesRoutes(router *gin.RouterGroup) {
|
|||
})
|
||||
}
|
||||
|
||||
func GetAllGrades() (scores map[int64]map[int64]*float64, err error) {
|
||||
if rows, errr := DBQuery("SELECT id_user, id_survey, SUM(score)/COUNT(*) FROM student_scores GROUP BY id_user, id_survey"); errr != nil {
|
||||
func gradeHandler(c *gin.Context) {
|
||||
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
|
||||
} else {
|
||||
defer rows.Close()
|
||||
|
||||
scores = map[int64]map[int64]*float64{}
|
||||
scores = map[int64]map[string]*float64{}
|
||||
|
||||
for rows.Next() {
|
||||
var id_user int64
|
||||
var id_survey int64
|
||||
var kind string
|
||||
var id int64
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
} else {
|
||||
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) {
|
||||
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
|
||||
} else {
|
||||
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)
|
||||
})
|
||||
|
||||
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) {
|
||||
|
|
|
|||
1
main.go
1
main.go
|
|
@ -58,6 +58,7 @@ func main() {
|
|||
var bind = flag.String("bind", ":8081", "Bind port/socket")
|
||||
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")
|
||||
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(&baseURL, "baseurl", baseURL, "URL prepended to each URL")
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
if (!s.Shown || s.Direct != nil) && !u.IsAdmin {
|
||||
if s.Direct != nil && !u.IsAdmin {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
||||
return
|
||||
}
|
||||
|
|
@ -60,26 +60,10 @@ func declareAPIAuthQuestionsRoutes(router *gin.RouterGroup) {
|
|||
|
||||
questionsRoutes := router.Group("/questions/:qid")
|
||||
questionsRoutes.Use(questionHandler)
|
||||
questionsRoutes.Use(questionUserAccessHandler)
|
||||
|
||||
questionsRoutes.GET("", func(c *gin.Context) {
|
||||
q := 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)
|
||||
c.JSON(http.StatusOK, c.MustGet("question").(*Question))
|
||||
})
|
||||
|
||||
declareAPIAuthProposalsRoutes(questionsRoutes)
|
||||
|
|
@ -114,6 +98,7 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
|
|||
|
||||
questionsRoutes := router.Group("/questions/:qid")
|
||||
questionsRoutes.Use(questionHandler)
|
||||
questionsRoutes.Use(questionUserAccessHandler)
|
||||
|
||||
questionsRoutes.PUT("", func(c *gin.Context) {
|
||||
current := c.MustGet("question").(*Question)
|
||||
|
|
@ -154,6 +139,7 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
|
|||
func declareAPIAdminUserQuestionsRoutes(router *gin.RouterGroup) {
|
||||
questionsRoutes := router.Group("/questions/:qid")
|
||||
questionsRoutes.Use(questionHandler)
|
||||
questionsRoutes.Use(questionUserAccessHandler)
|
||||
|
||||
questionsRoutes.GET("", func(c *gin.Context) {
|
||||
question := c.MustGet("question").(*Question)
|
||||
|
|
@ -203,6 +189,38 @@ func questionHandler(c *gin.Context) {
|
|||
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 {
|
||||
Id int64 `json:"id"`
|
||||
IdSurvey int64 `json:"id_survey"`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["alpine", "golang.org/x/oauth2", "github.com/aws/aws-sdk-go"],
|
||||
"automerge": true,
|
||||
"automergeType": "branch"
|
||||
}
|
||||
"extends": [
|
||||
"local>iac/renovate-config",
|
||||
"local>iac/renovate-config//automerge-common"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
334
repositories.go
334
repositories.go
|
|
@ -7,24 +7,30 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/drone/drone-go/drone"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
droneToken = ""
|
||||
droneConfig *http.Client
|
||||
droneEndpoint string
|
||||
droneToken = ""
|
||||
droneConfig *http.Client
|
||||
droneEndpoint string
|
||||
testsCallbackToken string
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&droneToken, "drone-token", droneToken, "Token for Drone Oauth")
|
||||
flag.StringVar(&droneEndpoint, "drone-endpoint", droneEndpoint, "Drone Endpoint")
|
||||
flag.StringVar(&testsCallbackToken, "tests-callback-token", testsCallbackToken, "Token of the callback token")
|
||||
}
|
||||
|
||||
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) {
|
||||
router.GET("/repositories", func(c *gin.Context) {
|
||||
var u *User
|
||||
|
|
@ -62,7 +73,6 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
if r.IdWork == work.(*Work).Id {
|
||||
// Is the URL used elsewhere?
|
||||
repos, _ := getRepositoriesByURI(r.URI)
|
||||
log.Println(repos)
|
||||
if len(repos) > 1 {
|
||||
r.AlreadyUsed = true
|
||||
}
|
||||
|
|
@ -90,12 +100,39 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
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
|
||||
if work, ok := c.Get("work"); ok {
|
||||
w = work.(*Work)
|
||||
} else if repository.IdWork > 0 {
|
||||
var err error
|
||||
w, err = getWork(int(repository.IdWork))
|
||||
w, err = getWork(repository.IdWork)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Unable to find the given work identifier."})
|
||||
return
|
||||
|
|
@ -123,7 +160,6 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
|
||||
// Is the URL used elsewhere?
|
||||
repos, _ := getRepositoriesByURI(repo.URI)
|
||||
log.Println(repos)
|
||||
if len(repos) > 1 {
|
||||
repo.AlreadyUsed = true
|
||||
}
|
||||
|
|
@ -156,8 +192,9 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
}
|
||||
})
|
||||
repositoriesRoutes.DELETE("", func(c *gin.Context) {
|
||||
loggeduser := c.MustGet("LoggedUser").(*User)
|
||||
repository := c.MustGet("repository").(*Repository)
|
||||
work, err := getWork(int(repository.IdWork))
|
||||
work, err := getWork(repository.IdWork)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||
return
|
||||
|
|
@ -166,7 +203,7 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
|
||||
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."})
|
||||
return
|
||||
}
|
||||
|
|
@ -189,11 +226,10 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
u = loggeduser
|
||||
}
|
||||
repo := c.MustGet("repository").(*Repository)
|
||||
work, err := getWork(int(repo.IdWork))
|
||||
work, err := getWork(repo.IdWork)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
|
@ -203,12 +239,12 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
var tag *string
|
||||
var rap RepositoryAdminPull
|
||||
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) {
|
||||
|
|
@ -265,6 +301,65 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
|
|||
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 {
|
||||
|
|
@ -309,7 +404,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
work, err := getWork(int(repo.IdWork))
|
||||
work, err := getWork(repo.IdWork)
|
||||
if err != nil {
|
||||
log.Println("Unable to getWork:", err.Error())
|
||||
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)
|
||||
if len(tmp) != 3 {
|
||||
TriggerTagUpdate(c, work, repo, user, nil)
|
||||
TriggerTagUpdate(c, work, repo, user, nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +430,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
|
|||
// Allow to use a secret for another tag
|
||||
if len(repos) > 1 {
|
||||
for _, r := range repos {
|
||||
w, err := getWork(int(r.IdWork))
|
||||
w, err := getWork(r.IdWork)
|
||||
if err != nil {
|
||||
log.Println("Unable to getWork:", err.Error())
|
||||
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)
|
||||
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."})
|
||||
return
|
||||
}
|
||||
|
|
@ -411,21 +536,27 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
|
|||
}
|
||||
}
|
||||
|
||||
client := drone.NewClient(droneEndpoint, droneConfig)
|
||||
result, err := client.BuildCreate("srs", "atsebay.t-worker", "", "master", map[string]string{
|
||||
env := map[string]string{
|
||||
"REPO_URL": repo.URI,
|
||||
"REPO_TAG": repo_tag,
|
||||
"LOGIN": login,
|
||||
"GROUPS": groups,
|
||||
"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 {
|
||||
log.Println("Unable to communicate with Drone:", err.Error())
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the extraction service."})
|
||||
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.Update()
|
||||
|
||||
|
|
@ -433,6 +564,116 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
|
|||
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 {
|
||||
Id int64 `json:"id"`
|
||||
IdUser int64 `json:"id_user"`
|
||||
|
|
@ -441,18 +682,41 @@ type Repository struct {
|
|||
Secret []byte `json:"secret,omitempty"`
|
||||
LastCheck *time.Time `json:"last_check"`
|
||||
DroneRef string `json:"drone_ref,omitempty"`
|
||||
LastTests *time.Time `json:"last_tests"`
|
||||
TestsRef string `json:"tests_ref,omitempty"`
|
||||
AlreadyUsed bool `json:"already_used,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
} 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); 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
|
||||
}
|
||||
repositories = append(repositories, &repo)
|
||||
|
|
@ -466,14 +730,14 @@ func (u *User) GetRepositories() (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
|
||||
} 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); 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)
|
||||
|
|
@ -488,13 +752,19 @@ func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
|
|||
|
||||
func getRepository(id int) (r *Repository, err error) {
|
||||
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
|
||||
}
|
||||
|
||||
func (u *User) getRepository(id int) (r *Repository, err error) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -505,17 +775,17 @@ func (u *User) NewRepository(w *Work, uri string) (*Repository, error) {
|
|||
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
|
||||
} else if rid, err := res.LastInsertId(); err != nil {
|
||||
return nil, err
|
||||
} 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) {
|
||||
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
|
||||
} else {
|
||||
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) {
|
||||
router.GET("/@fs/*_", serveOrReverse(""))
|
||||
router.GET("/", serveOrReverse(""))
|
||||
router.GET("/_app/*_", serveOrReverse(""))
|
||||
router.GET("/auth/", serveOrReverse("/"))
|
||||
router.GET("/bug-bounty", serveOrReverse("/"))
|
||||
router.GET("/categories", serveOrReverse("/"))
|
||||
router.GET("/categories/*_", serveOrReverse("/"))
|
||||
router.GET("/donnees-personnelles", serveOrReverse("/"))
|
||||
router.GET("/grades", serveOrReverse("/"))
|
||||
router.GET("/grades/*_", serveOrReverse("/"))
|
||||
router.GET("/help", serveOrReverse("/"))
|
||||
router.GET("/keys", serveOrReverse("/"))
|
||||
router.GET("/results", serveOrReverse("/"))
|
||||
router.GET("/surveys", serveOrReverse("/"))
|
||||
router.GET("/surveys/*_", serveOrReverse("/"))
|
||||
router.GET("/users", serveOrReverse("/"))
|
||||
|
|
@ -71,7 +74,7 @@ func declareStaticRoutes(router *gin.Engine) {
|
|||
router.GET("/.svelte-kit/*_", serveOrReverse(""))
|
||||
router.GET("/node_modules/*_", serveOrReverse(""))
|
||||
router.GET("/@vite/*_", serveOrReverse(""))
|
||||
router.GET("/__vite_ping", serveOrReverse(""))
|
||||
router.GET("/@fs/*_", serveOrReverse(""))
|
||||
router.GET("/src/*_", serveOrReverse(""))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const SharingTime = 15 * time.Minute
|
||||
const SharingTime = 10 * time.Minute
|
||||
|
||||
var (
|
||||
s3_endpoint string
|
||||
|
|
|
|||
104
surveys.go
104
surveys.go
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -62,19 +63,14 @@ func declareAPISurveysRoutes(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
s := 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"})
|
||||
}
|
||||
c.JSON(http.StatusOK, c.MustGet("survey").(*Survey))
|
||||
})
|
||||
}
|
||||
|
||||
func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
||||
surveysRoutes := router.Group("/surveys/:sid")
|
||||
surveysRoutes.Use(surveyHandler)
|
||||
surveysRoutes.Use(surveyUserAccessHandler)
|
||||
|
||||
surveysRoutes.GET("/score", func(c *gin.Context) {
|
||||
var u *User
|
||||
|
|
@ -85,7 +81,34 @@ func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
|
|||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
c.JSON(http.StatusOK, map[string]string{"score": "N/A"})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, map[string]float64{"score": *score})
|
||||
c.JSON(http.StatusOK, map[string]float64{"score": math.Round(*score*10) / 10})
|
||||
}
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
|
||||
|
|
@ -123,7 +146,7 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) {
|
|||
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)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey creation: %s", err.Error())})
|
||||
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)
|
||||
declareAPIAdminDirectRoutes(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) {
|
||||
u := c.MustGet("LoggedUser").(*User)
|
||||
w := c.MustGet("survey").(*Survey)
|
||||
s := c.MustGet("survey").(*Survey)
|
||||
|
||||
if u.IsAdmin {
|
||||
c.Next()
|
||||
} else if w.Shown && (w.Group == "" || strings.Contains(u.Groups, ","+w.Group+",")) {
|
||||
c.Next()
|
||||
} else {
|
||||
if !s.checkUserAccessToSurvey(u) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Survey not found."})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
type Survey struct {
|
||||
Id int64 `json:"id"`
|
||||
IdCategory int64 `json:"id_category"`
|
||||
Title string `json:"title"`
|
||||
Promo uint `json:"promo"`
|
||||
Group string `json:"group"`
|
||||
|
|
@ -226,14 +279,14 @@ type Survey struct {
|
|||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
surveys = append(surveys, &s)
|
||||
|
|
@ -255,7 +308,7 @@ func getSurvey(id int) (s *Survey, err error) {
|
|||
}
|
||||
|
||||
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[int64(id)] = s
|
||||
|
|
@ -263,13 +316,13 @@ func getSurvey(id int) (s *Survey, err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func NewSurvey(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 {
|
||||
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 (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
|
||||
} else if sid, err := res.LastInsertId(); err != nil {
|
||||
return nil, err
|
||||
} 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 {
|
||||
score = v
|
||||
} 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 {
|
||||
*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) {
|
||||
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
|
||||
} else {
|
||||
defer rows.Close()
|
||||
|
|
@ -327,7 +380,7 @@ func (s Survey) GetScores() (scores map[int64]*float64, err 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
|
||||
} else {
|
||||
_surveys_cache_mutex.Lock()
|
||||
|
|
@ -338,6 +391,7 @@ func (s *Survey) Update() (*Survey, 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 {
|
||||
return 0, err
|
||||
} 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=. ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.29",
|
||||
"@sveltejs/kit": "^1.0.0-next.324",
|
||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.6.2",
|
||||
"prettier-plugin-svelte": "^2.7.0",
|
||||
"svelte": "^3.48.0",
|
||||
"svelte-check": "^2.7.0",
|
||||
"svelte-preprocess": "^4.10.6",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.35.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte-check": "^3.4.3",
|
||||
"svelte-preprocess": "^5.0.3",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.6.4"
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.5",
|
||||
"svelte-frappe-charts": "^1.9.1",
|
||||
"vite": "^3.0.4"
|
||||
"svelte-frappe-charts": "^1.9.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
<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"}
|
||||
<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}
|
||||
{state.status}
|
||||
{/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;
|
||||
|
||||
function refreshProposals() {
|
||||
let req = question.getProposals();
|
||||
let req = question.getProposals(secret);
|
||||
|
||||
req.then((proposals) => {
|
||||
const proposal_idx = { };
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
proposal_idx[proposal.id] = new String(data.labels.length - 1);
|
||||
}
|
||||
|
||||
req_responses = question.getResponses();
|
||||
req_responses = question.getResponses(secret);
|
||||
req_responses.then((responses) => {
|
||||
for (const res of responses) {
|
||||
const rsplt = res.value.split(',');
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
}
|
||||
let req_proposals = null;
|
||||
export let proposals = null;
|
||||
export let secret = null;
|
||||
let req_responses = null;
|
||||
let mean = null;
|
||||
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
|
||||
if (!proposals) {
|
||||
if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) {
|
||||
req_responses = question.getResponses();
|
||||
req_responses = question.getResponses(secret);
|
||||
req_responses.then((responses) => {
|
||||
const values = [];
|
||||
const proposal_idx = { };
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { CorrectionTemplate } from '../lib/correctionTemplates';
|
||||
import { CorrectionTemplate } from '$lib/correctionTemplates';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { user } from '../stores/user';
|
||||
import { autoCorrection } from '../lib/correctionTemplates';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { autoCorrection } from '$lib/correctionTemplates';
|
||||
|
||||
export let cts = null;
|
||||
export let rid = 0;
|
||||
|
|
@ -38,14 +38,37 @@
|
|||
for (const t of templates) {
|
||||
if (my_tpls[t.id] === undefined && cts[t.id.toString()]) {
|
||||
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>
|
||||
|
||||
<svelte:window bind:scrollY={scrollY}/>
|
||||
<form
|
||||
class="row"
|
||||
bind:this={element}
|
||||
on:submit|preventDefault={submitCorrection}
|
||||
>
|
||||
<div class="col-auto">
|
||||
|
|
@ -69,6 +92,7 @@
|
|||
>
|
||||
<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}"
|
||||
>
|
||||
{template.label}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
import QuestionProposals from './QuestionProposals.svelte';
|
||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||
import CorrectionResponseFooter from './CorrectionResponseFooter.svelte';
|
||||
import { autoCorrection } from '../lib/correctionTemplates';
|
||||
import { getUser } from '../lib/users';
|
||||
import { autoCorrection } from '$lib/correctionTemplates';
|
||||
import { getUser } from '$lib/users';
|
||||
|
||||
export let cts = null;
|
||||
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))));
|
||||
}
|
||||
|
||||
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() {
|
||||
for (const r of filteredResponses) {
|
||||
const my_correction = { };
|
||||
let has_no_lost_answer = false;
|
||||
let completed_correction = false;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (tpl.score >= 0) has_no_lost_answer = true;
|
||||
if (!tpl.regexp && tpl.label) continue;
|
||||
|
||||
if (tpl.regexp && (tpl.regexp[0] == '!' && !r.value.match(tpl.regexp.substring(1))) || r.value.match(tpl.regexp)) {
|
||||
my_correction[tpl.id] = true;
|
||||
completed_correction = true;
|
||||
} else {
|
||||
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);
|
||||
r.score = auto.score;
|
||||
r.score_explaination = auto.score_explaination;
|
||||
|
|
@ -88,7 +114,7 @@
|
|||
class="card-text"
|
||||
style="white-space: pre-line"
|
||||
>
|
||||
{response.value}
|
||||
{@html hilightText(escapeTags(response.value), templates)}
|
||||
</p>
|
||||
{/if}
|
||||
<ResponseCorrected
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
</span>
|
||||
<div>
|
||||
{#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]}
|
||||
{users[user].login}
|
||||
{:else}
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
<span>
|
||||
{rep}
|
||||
</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]]}
|
||||
{users[res[rep]].login}
|
||||
{:else}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
import QuestionHeader from './QuestionHeader.svelte';
|
||||
import QuestionProposals from './QuestionProposals.svelte';
|
||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||
import { user } from '../stores/user';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { user } from '../stores/user';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { QuestionProposal } from '../lib/questions';
|
||||
import { QuestionProposal } from '$lib/questions';
|
||||
|
||||
export let edit = false;
|
||||
export let proposals = [];
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { user } from '../stores/user';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
export let response = 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>
|
||||
import { getSurveys } from '../lib/surveys';
|
||||
import { getUsers, getGrades, getPromos } from '../lib/users';
|
||||
import { getCategories } from '$lib/categories';
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getUsers, getGrades, getPromos } from '$lib/users';
|
||||
|
||||
export let promo = null;
|
||||
export let category = null;
|
||||
</script>
|
||||
|
||||
{#await getPromos() then promos}
|
||||
|
|
@ -15,12 +17,24 @@
|
|||
</select>
|
||||
</div>
|
||||
{/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>
|
||||
Étudiants {#if promo !== null}{promo}{/if}
|
||||
<small class="text-muted">Notes</small>
|
||||
</h2>
|
||||
|
||||
{#await getSurveys()}
|
||||
{#await getSurveys(true)}
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des questionnaires corrigés…
|
||||
|
|
@ -38,9 +52,15 @@
|
|||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Login</th>
|
||||
{#each surveys as survey (survey.id)}
|
||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
||||
<th><a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a></th>
|
||||
{#each surveys as survey}
|
||||
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
|
||||
<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}
|
||||
{/each}
|
||||
</tr>
|
||||
|
|
@ -57,13 +77,15 @@
|
|||
</tr>
|
||||
{:then users}
|
||||
{#each users as user (user.id)}
|
||||
{#if promo === null || user.promo === promo}
|
||||
{#if !promo || user.promo == promo}
|
||||
<tr>
|
||||
<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>
|
||||
{#each surveys as survey (survey.id)}
|
||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
||||
<td>{grades[user.id] && grades[user.id][survey.id]?grades[user.id][survey.id]:""}</td>
|
||||
{#each surveys as survey}
|
||||
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
|
||||
<td>
|
||||
{grades[user.id] && grades[user.id][survey.kind + "." + survey.id]?grades[user.id][survey.kind + "." + survey.id]:""}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import DateFormat from '../components/DateFormat.svelte';
|
||||
import { getUserRendu } from '../lib/works';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { getUserRendu } from '$lib/works';
|
||||
|
||||
export let work = null;
|
||||
export let user = null;
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
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 { ToastsStore } from '../stores/toasts';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let survey = null;
|
||||
|
|
@ -33,10 +34,14 @@
|
|||
})
|
||||
}
|
||||
|
||||
let duplicateInProgress = false;
|
||||
function duplicateSurvey() {
|
||||
duplicateInProgress = true;
|
||||
survey.duplicate().then((response) => {
|
||||
duplicateInProgress = false;
|
||||
goto(`surveys/${response.id}`);
|
||||
}).catch((error) => {
|
||||
duplicateInProgress = false;
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
|
|
@ -67,6 +72,21 @@
|
|||
</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="col-sm-3 text-sm-end">
|
||||
<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">
|
||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -117,7 +137,7 @@
|
|||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -141,14 +161,19 @@
|
|||
<div class="form-group row">
|
||||
<div class="col-sm-10">
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
{#if survey.id}
|
||||
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress}>
|
||||
{#if survey.id || duplicateInProgress}
|
||||
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress || duplicateInProgress}>
|
||||
{#if deleteInProgress}
|
||||
<div class="spinner-border spinner-border-sm text-light me-1" role="status"></div>
|
||||
{/if}
|
||||
Supprimer
|
||||
</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}
|
||||
</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">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { user } from '../stores/user';
|
||||
import DateFormat from '../components/DateFormat.svelte';
|
||||
import SurveyBadge from '../components/SurveyBadge.svelte';
|
||||
import SubmissionStatus from '../components/SubmissionStatus.svelte';
|
||||
import { getSurveys } from '../lib/surveys';
|
||||
import { getScore } from '../lib/users';
|
||||
import { user } from '$lib/stores/user';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
|
||||
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
|
||||
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
|
||||
import { getCategories } from '$lib/categories';
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getScore } from '$lib/users';
|
||||
|
||||
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) {
|
||||
if (survey.kind === "w") {
|
||||
goto(`works/${survey.id}`);
|
||||
|
|
@ -40,7 +49,11 @@
|
|||
<th>Intitulé</th>
|
||||
<th>Date</th>
|
||||
{#if $user}
|
||||
<th>Score</th>
|
||||
{#if $user.is_admin}
|
||||
<th>À corriger</th>
|
||||
{:else}
|
||||
<th>Score</th>
|
||||
{/if}
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -56,13 +69,39 @@
|
|||
{#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 $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">
|
||||
{survey.promo}
|
||||
</th>
|
||||
</tr>
|
||||
{/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>
|
||||
{#if !survey.shown}<i class="bi bi-eye-slash-fill" title="Ce questionnaire n'est pas affiché aux étudiants"></i>{/if}
|
||||
{survey.title}
|
||||
|
|
@ -84,14 +123,30 @@
|
|||
</td>
|
||||
{/if}
|
||||
{#if $user}
|
||||
{#if !survey.corrected}
|
||||
{#if !survey.corrected && !$user.is_admin}
|
||||
<td>N/A</td>
|
||||
{:else}
|
||||
<td>
|
||||
{#await getScore(survey)}
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
{: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}
|
||||
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
||||
{/await}
|
||||
|
|
@ -99,6 +154,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import { user } from '../stores/user';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import QuestionForm from '../components/QuestionForm.svelte';
|
||||
import { Question } from '../lib/questions';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
import QuestionForm from '$lib/components/QuestionForm.svelte';
|
||||
import { Question } from '$lib/questions';
|
||||
|
||||
export let survey = null;
|
||||
export let id_user = null;
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
survey.submitAnswers(res, id_user).then((response) => {
|
||||
submitInProgress = false;
|
||||
ToastsStore.addToast({
|
||||
msg: "Vos réponses ont bien étés sauvegardées.",
|
||||
msg: "Vos réponses ont bien été sauvegardées.",
|
||||
color: "success",
|
||||
title: "Questionnaire",
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
</script>
|
||||
|
||||
<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">
|
||||
import { getKeys, getKey, Key } from '../lib/key';
|
||||
import { getKeys, getKey, Key } from '$lib/key';
|
||||
|
||||
export let student = null;
|
||||
</script>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { getSurveys } from '../lib/surveys';
|
||||
import { getUser, getUserGrade, getUserScore } from '../lib/users';
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getUser, getUserGrade, getUserScore } from '$lib/users';
|
||||
|
||||
export let student = null;
|
||||
export let allPromos = false;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { user } from '../stores/user';
|
||||
import DateFormat from '../components/DateFormat.svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
|
@ -2,8 +2,10 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { getCategories } from '$lib/categories';
|
||||
import { getGradationRepositories, syncGradationRepositories } from '$lib/gradation';
|
||||
import DateTimeInput from './DateTimeInput.svelte';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let work = null;
|
||||
|
|
@ -38,6 +40,7 @@
|
|||
})
|
||||
}
|
||||
|
||||
let grepositoriesP = getGradationRepositories();
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={saveWork}>
|
||||
|
|
@ -62,6 +65,21 @@
|
|||
</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="col-sm-3 text-sm-end">
|
||||
<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">
|
||||
<label for="submissionurl" class="col-form-label col-form-label-sm">URL validation la soumission</label>
|
||||
</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}>
|
||||
</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="col-sm-3 text-sm-end">
|
||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -111,7 +159,7 @@
|
|||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||
</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} />
|
||||
</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>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import BuildState from '../components/BuildState.svelte';
|
||||
import DateFormat from '../components/DateFormat.svelte';
|
||||
import { WorkRepository, getRemoteRepositories, getRepositories } from '../lib/repositories';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import BuildState from '$lib/components/BuildState.svelte';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { WorkRepository, getRemoteRepositories, getRepositories } from '$lib/repositories';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
|
@ -159,12 +159,12 @@
|
|||
<span>Récupération de vos dépôts GitLab …</span>
|
||||
</div>
|
||||
{:then rrepos}
|
||||
<select class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
|
||||
{#each rrepos as r (r.Id)}
|
||||
<select id="repolist" class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
|
||||
{#each rrepos as r (r.ssh_url_to_repo)}
|
||||
<option value={r.ssh_url_to_repo}>{r.path_with_namespace}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label>Dépôt GitLab pour ce travail :</label>
|
||||
<label for="repolist">Dépôt GitLab pour ce travail :</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 btn btn-primary"
|
||||
|
|
@ -185,6 +185,52 @@
|
|||
>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</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>
|
||||
{/if}
|
||||
{/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;
|
||||
}
|
||||
|
||||
async getProposals() {
|
||||
const res = await fetch(`api/questions/${this.id}/proposals`, {
|
||||
async getProposals(secret) {
|
||||
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',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
|
|
@ -91,8 +93,10 @@ export class Question {
|
|||
}
|
||||
}
|
||||
|
||||
async getResponses() {
|
||||
const res = await fetch(`api/surveys/${this.id_survey}/questions/${this.id}/responses`, {
|
||||
async getResponses(secret) {
|
||||
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',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
|
|
@ -161,3 +165,17 @@ export async function getQuestions(sid) {
|
|||
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;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}`:`api/repositories/${this.id}`, {
|
||||
async delete(userid) {
|
||||
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',
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: !tag || tag.length == 0?null:JSON.stringify(tag)
|
||||
body: !admin_struct?{}:JSON.stringify(admin_struct)
|
||||
});
|
||||
if (res.status == 200) {
|
||||
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) {
|
||||
let url = this.id?`repositories/${this.id}`:'repositories';
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,10 @@ function createUserStore() {
|
|||
set: (auth) => {
|
||||
update((m) => auth);
|
||||
},
|
||||
update: (res_auth, cb=null) => {
|
||||
update: (res_auth) => {
|
||||
if (res_auth.status === 200) {
|
||||
res_auth.json().then((auth) => {
|
||||
update((m) => (Object.assign(m?m:{}, auth)));
|
||||
|
||||
if (cb) {
|
||||
cb(my);
|
||||
}
|
||||
});
|
||||
} else if (res_auth.status >= 400 && res_auth.status < 500) {
|
||||
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();
|
||||
|
|
@ -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_category = id_category;
|
||||
this.title = title;
|
||||
this.promo = promo;
|
||||
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() {
|
||||
const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', {
|
||||
method: this.id?'PUT':'POST',
|
||||
|
|
@ -104,31 +117,35 @@ export class Survey {
|
|||
for (const q of questions) {
|
||||
const oldQuestionId = q.id;
|
||||
|
||||
// This will create a new question with the same parameters
|
||||
delete q.id;
|
||||
|
||||
// Also alter id_survey
|
||||
q.id_survey = response.id;
|
||||
q.save().then((question) => {
|
||||
q.id = oldQuestionId;
|
||||
|
||||
// Now recopy proposals
|
||||
if (q.kind == "mcq" || q.kind == "ucq") {
|
||||
q.getProposals().then((proposals) => {
|
||||
for (const p of proposals) {
|
||||
delete p.id;
|
||||
p.id_question = question.id;
|
||||
p.save();
|
||||
}
|
||||
});
|
||||
// This save will create
|
||||
const question = await q.save();
|
||||
|
||||
// Revert to the old question ID to perform the next retrievals
|
||||
q.id = oldQuestionId;
|
||||
|
||||
// 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
|
||||
getCorrectionTemplates(oldQuestionId).then((cts) => {
|
||||
for (const ct of cts) {
|
||||
delete ct.id;
|
||||
ct.id_question = question.id;
|
||||
ct.save();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Now recopy correction templates
|
||||
const cts = await getCorrectionTemplates(oldQuestionId);
|
||||
for (const ct of cts) {
|
||||
delete ct.id;
|
||||
ct.id_question = question.id;
|
||||
ct.save();
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
@ -185,3 +202,12 @@ export async function getSurvey(sid) {
|
|||
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 {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Grade } from '$lib/grades';
|
||||
|
||||
export class Work {
|
||||
constructor(res) {
|
||||
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_category = id_category;
|
||||
this.title = title;
|
||||
this.promo = promo;
|
||||
this.group = group;
|
||||
|
|
@ -16,6 +19,7 @@ export class Work {
|
|||
this.description = description;
|
||||
this.descr_raw = descr_raw;
|
||||
this.submission_url = submission_url;
|
||||
this.gradation_repo = gradation_repo;
|
||||
this.corrected = corrected;
|
||||
if (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) {
|
||||
const res = await fetch(uid?`api/users/${uid}/works/${this.id}/submission`:`api/works/${this.id}/submission`, {
|
||||
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() {
|
||||
const res = await fetch(`api/works/${this.id}/grades`, {
|
||||
method: 'GET',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
return (await res.json()).map((g) => new Grade(g));
|
||||
} else {
|
||||
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>
|
||||
import AuthButton from '../components/AuthButton.svelte';
|
||||
import Toaster from '../components/Toaster.svelte';
|
||||
import AuthButton from '$lib/components/AuthButton.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { refresh_auth, user } from '$lib/stores/user';
|
||||
|
||||
export let rroute = '';
|
||||
export let data;
|
||||
|
||||
function switchAdminMode() {
|
||||
var tmp = $user.is_admin;
|
||||
|
|
@ -71,6 +29,11 @@
|
|||
<title>ЕРІТА: MCQ and others courses related stuff</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $user && $user.banner}
|
||||
<div class="bg-danger text-white text-center py-1 fw-bolder">
|
||||
{$user.banner}
|
||||
</div>
|
||||
{/if}
|
||||
{#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>
|
||||
{/if}
|
||||
|
|
@ -86,7 +49,11 @@
|
|||
<div class="collapse navbar-collapse" id="loggedMenu">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<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>
|
||||
{#if isSRS}
|
||||
<li class="nav-item">
|
||||
|
|
@ -94,19 +61,25 @@
|
|||
</li>
|
||||
{/if}
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
{#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}
|
||||
<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 class="navbar-nav ms-auto">
|
||||
|
|
@ -125,13 +98,12 @@
|
|||
</li>
|
||||
{/if}
|
||||
<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;">
|
||||
</a>
|
||||
<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={rroute === 'help'} href="help">Besoin d'aide ?</a></li>
|
||||
<li><a class="dropdown-item" class:active={rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</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={data.rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={disconnectCurrentUser}>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { user } from '../stores/user';
|
||||
import { getUser, getUserNeedingHelp } from '../lib/users';
|
||||
import DateFormat from '../components/DateFormat.svelte';
|
||||
import SurveyList from '../components/SurveyList.svelte';
|
||||
import ValidateSubmissions from '../components/ValidateSubmissions.svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { getUser, getUserNeedingHelp } from '$lib/users';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import SurveyList from '$lib/components/SurveyList.svelte';
|
||||
import ValidateSubmissions from '$lib/components/ValidateSubmissions.svelte';
|
||||
|
||||
let direct = null;
|
||||
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import AuthButton from '../components/AuthButton.svelte';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { user } from '../stores/user';
|
||||
import AuthButton from '$lib/components/AuthButton.svelte';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
let auth = { username: "", password: "" };
|
||||
let pleaseWait = false;
|
||||
|
|
@ -55,6 +55,7 @@
|
|||
<form class="col" on:submit|preventDefault={logmein}>
|
||||
<h2>Accès à votre compte</h2>
|
||||
<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>
|
||||
<label for="login">CRI login</label>
|
||||
</div>
|
||||
|
|
@ -59,12 +59,28 @@
|
|||
<div class="alert alert-warning d-flex">
|
||||
<i class="bi bi-exclamation-triangle me-3"></i>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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-header">
|
||||
Il était toujours possible de répondre aux questionnaires après l'heure de clôture.
|
||||
|
|
@ -81,7 +97,7 @@
|
|||
</div>
|
||||
<p class="card-text mt-3">
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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).
|
||||
</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>
|
||||
import { user } from '../stores/user';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
function needhelp() {
|
||||
fetch('api/help', {
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
<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,
|
||||
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>
|
||||
|
||||
{#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 :
|
||||
<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}
|
||||
>
|
||||
Clique ce bouton
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { deleteKey, getKeys, getKey, Key } from '../lib/key';
|
||||
import { user } from '../stores/user';
|
||||
import { ToastsStore } from '../stores/toasts';
|
||||
import { deleteKey, getKeys, getKey, Key } from '$lib/key';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
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).
|
||||
</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>.
|
||||
</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">
|
||||
import { user } from '../../stores/user';
|
||||
import SurveyList from '../../components/SurveyList.svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
import SurveyList from '$lib/components/SurveyList.svelte';
|
||||
</script>
|
||||
|
||||
<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">
|
||||
|
||||
export let survey;
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
{#await survey}
|
||||
{#await data.survey}
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<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