Compare commits
487 Commits
Author | SHA1 | Date |
---|---|---|
Emmanuel Viennet | 18b1f00586 | |
Iziram | 6b985620e9 | |
Iziram | 4d234ba353 | |
Iziram | 5d45fcf656 | |
Iziram | 0a5919b788 | |
Iziram | 09f4525e66 | |
Emmanuel Viennet | 0bc57807de | |
Emmanuel Viennet | 87aaf12d27 | |
Emmanuel Viennet | c8ab9b9b6c | |
Emmanuel Viennet | ad7b48e110 | |
Emmanuel Viennet | f2ce16f161 | |
Emmanuel Viennet | 1ddf9b6ab8 | |
Emmanuel Viennet | 0a2e39cae1 | |
Emmanuel Viennet | a194b4b6e0 | |
Emmanuel Viennet | cbe85dfb7d | |
Emmanuel Viennet | beba69bfe4 | |
Emmanuel Viennet | 41fec29452 | |
Emmanuel Viennet | 9bd05ea241 | |
Emmanuel Viennet | 58b831513d | |
Emmanuel Viennet | b861aba6a3 | |
Emmanuel Viennet | c2443c361f | |
Emmanuel Viennet | ab4731bd43 | |
Emmanuel Viennet | c17bc8b61b | |
Emmanuel Viennet | e44a5ee55d | |
Emmanuel Viennet | a747ed22e2 | |
Emmanuel Viennet | 5d0a932634 | |
Emmanuel Viennet | 2b150cf521 | |
Emmanuel Viennet | 5a5ddcacd7 | |
Emmanuel Viennet | 3f6e65b9da | |
Emmanuel Viennet | 5eba6170a5 | |
Emmanuel Viennet | bd9bf87112 | |
Emmanuel Viennet | a0e2af481f | |
Emmanuel Viennet | 42e8f97441 | |
Emmanuel Viennet | 8ec0171ca0 | |
Emmanuel Viennet | 6dfab2d843 | |
Emmanuel Viennet | 523ec59833 | |
Emmanuel Viennet | bde6325391 | |
Emmanuel Viennet | 0577347622 | |
Emmanuel Viennet | 28d46e413d | |
Emmanuel Viennet | 126ea0741a | |
Emmanuel Viennet | a5b5f49f76 | |
Iziram | b7ab10bf4e | |
Emmanuel Viennet | 3e0b19c4a8 | |
Emmanuel Viennet | 1dd5187fae | |
Iziram | 9a3a7d33b2 | |
Iziram | a7569fe4f5 | |
Iziram | 79e973f06d | |
Emmanuel Viennet | b6940e4882 | |
Emmanuel Viennet | 1f24095c57 | |
Emmanuel Viennet | 0ed2455028 | |
Emmanuel Viennet | b841b2f708 | |
Iziram | 0fa1478138 | |
Iziram | 85ad7b5f29 | |
Emmanuel Viennet | 6bfd461bf2 | |
Emmanuel Viennet | e1f1a95a14 | |
Emmanuel Viennet | 70e3006981 | |
Emmanuel Viennet | bae46c2794 | |
Iziram | b1055a4ebe | |
Iziram | b2ef6a4c53 | |
Iziram | a7c7bd655d | |
Iziram | 1309043a98 | |
Iziram | a75b41ca5f | |
Emmanuel Viennet | 8df25ca02f | |
Emmanuel Viennet | 61f9dddeb6 | |
Emmanuel Viennet | a1f5340935 | |
Emmanuel Viennet | 68128c27d5 | |
Emmanuel Viennet | 8ecaa2bed0 | |
Emmanuel Viennet | 7c61dd8d63 | |
Emmanuel Viennet | f493ba344f | |
Emmanuel Viennet | f5079d9aef | |
Emmanuel Viennet | 55add2ffb3 | |
Emmanuel Viennet | 5865b67652 | |
Emmanuel Viennet | 3c8b088d5e | |
Emmanuel Viennet | 2da359ae41 | |
Emmanuel Viennet | 09ec53f573 | |
Emmanuel Viennet | 3787e0145a | |
Emmanuel Viennet | edf989ee04 | |
Emmanuel Viennet | 203f3a5342 | |
Emmanuel Viennet | 161f8476ca | |
Emmanuel Viennet | d419d75515 | |
Emmanuel Viennet | f23630d7fd | |
Emmanuel Viennet | fa0417f0b1 | |
Emmanuel Viennet | 12256dc3d4 | |
Emmanuel Viennet | 46529917ea | |
Emmanuel Viennet | 2367984848 | |
Emmanuel Viennet | 46c86d2928 | |
Emmanuel Viennet | 715e4f94ee | |
Iziram | b2e6ef63b9 | |
Iziram | 30560e5860 | |
Iziram | 0fbcfb1124 | |
Iziram | 2daae1c9c5 | |
Emmanuel Viennet | 635269ff36 | |
Emmanuel Viennet | 4aa30a40bd | |
Emmanuel Viennet | 03c03f3725 | |
Emmanuel Viennet | 29eb8c297b | |
Emmanuel Viennet | 38032a8c09 | |
Emmanuel Viennet | 2f2d98954c | |
Emmanuel Viennet | 2e5d94f048 | |
Emmanuel Viennet | 1b1b8ebdc4 | |
Emmanuel Viennet | 9c6db169f3 | |
Iziram | 8ded16b94f | |
Iziram | 5d10ee467e | |
Emmanuel Viennet | 763f60fb3d | |
Iziram | 7af0dd1e1e | |
Emmanuel Viennet | dece9a82d1 | |
Emmanuel Viennet | 0262b6e2ac | |
Emmanuel Viennet | f8f47e05ff | |
Iziram | b74d525c28 | |
Iziram | c617ee321a | |
Iziram | 56ec4ba43d | |
Iziram | d14f7e21b7 | |
Iziram | c3cb1da561 | |
Iziram | cce60d432d | |
Iziram | 4386994f7d | |
Iziram | fddfddfa7b | |
Iziram | 39dca32d2e | |
Iziram | e2b9cd3ded | |
Iziram | be227f4a2f | |
Emmanuel Viennet | 959a98d0a2 | |
Emmanuel Viennet | 35a038fd3a | |
Emmanuel Viennet | b46556c189 | |
Iziram | 71f90f5261 | |
Iziram | 1b037d6c7c | |
Emmanuel Viennet | 60a97b7baf | |
Iziram | 0332553587 | |
Iziram | 958cf435c8 | |
Iziram | c69e9c34a0 | |
Iziram | 17f8771b0b | |
Iziram | 7eb41fb2eb | |
Iziram | a79ca4a17d | |
Emmanuel Viennet | 411ef8ae0d | |
Emmanuel Viennet | 169bf17fdd | |
Emmanuel Viennet | 75d4c110a8 | |
Emmanuel Viennet | 9003a2ca87 | |
Emmanuel Viennet | 55ecaa45a9 | |
Emmanuel Viennet | ab39454a0d | |
Iziram | 5158bd0c8f | |
Iziram | 21b2e0f582 | |
Emmanuel Viennet | e56cbfc5a2 | |
Emmanuel Viennet | 9cdab8d1ed | |
Cléo Baras | 7cdba43e86 | |
Iziram | 079348bb87 | |
Iziram | c882e0d6a0 | |
Cléo Baras | 9c7576154c | |
Cléo Baras | ce0d5ec9fd | |
Iziram | 3d6be2f200 | |
Cléo Baras | e675064cae | |
Iziram | 185e061f01 | |
Iziram | e4c889ec8a | |
Emmanuel Viennet | 7ef45e0bac | |
Iziram | f242fee5ff | |
Emmanuel Viennet | c960d943d2 | |
Emmanuel Viennet | 741168a065 | |
Emmanuel Viennet | 5c9126d263 | |
Emmanuel Viennet | ce63b7f2f5 | |
Emmanuel Viennet | 5e5cb015d0 | |
Cléo Baras | 5fc1800f70 | |
Cléo Baras | 2459356245 | |
Cléo Baras | b1602f0cf3 | |
Cléo Baras | ba28d5f3c8 | |
Cléo Baras | b9b9a172c7 | |
Cléo Baras | c2a66b607f | |
Cléo Baras | 802e8f4648 | |
Iziram | 3184d5d92e | |
Cléo Baras | cf7d7d2db8 | |
Cléo Baras | 5ea65433be | |
Cléo Baras | 35a20c3307 | |
Cléo Baras | 8acd9a12d4 | |
Cléo Baras | 2020114c1b | |
Cléo Baras | a93aa19449 | |
Iziram | c620c3b0e1 | |
Iziram | c2e77846b9 | |
Cléo Baras | 28b25ad681 | |
Cléo Baras | 5ea79c03a3 | |
Emmanuel Viennet | fdcf6388f5 | |
Iziram | 9dcaf70e18 | |
Emmanuel Viennet | 20d4b4e1b3 | |
Emmanuel Viennet | aaaf41250a | |
Iziram | b3b47a755f | |
Emmanuel Viennet | bc5292b165 | |
Emmanuel Viennet | ee601071f5 | |
Emmanuel Viennet | 0cf3b0a782 | |
Emmanuel Viennet | 49a5ec488d | |
Cléo Baras | a50bbe9223 | |
Cléo Baras | 57d616da1a | |
Emmanuel Viennet | c0a965d774 | |
Emmanuel Viennet | 1c01d987be | |
Cléo Baras | 21a794a760 | |
Emmanuel Viennet | 41944bcd29 | |
Cléo Baras | 960f8a3462 | |
Cléo Baras | 6821a02956 | |
Emmanuel Viennet | 47a42d897e | |
Emmanuel Viennet | 7f32f1fb99 | |
Cléo Baras | eb56182407 | |
Cléo Baras | 02b057ca5a | |
Cléo Baras | eff28d64f9 | |
Emmanuel Viennet | 81fab97018 | |
Emmanuel Viennet | a8a711b30a | |
Emmanuel Viennet | 46cdaf75b8 | |
Emmanuel Viennet | d1d89cc427 | |
Emmanuel Viennet | 61d35ddac0 | |
Emmanuel Viennet | c492cf550a | |
Emmanuel Viennet | 2dd7154036 | |
Emmanuel Viennet | 13e7bd4512 | |
Emmanuel Viennet | f1ce70e6de | |
Emmanuel Viennet | a8ff540e95 | |
Emmanuel Viennet | cc3f5d393f | |
Emmanuel Viennet | 7c794c01d1 | |
Cléo Baras | 746314b2fb | |
Emmanuel Viennet | 624ea39edd | |
Emmanuel Viennet | 853bc31422 | |
Emmanuel Viennet | 09d59848d6 | |
Emmanuel Viennet | f31eca97bb | |
Emmanuel Viennet | 3844ae46d1 | |
Emmanuel Viennet | fae9fbdd09 | |
Cléo Baras | 40a57a9b86 | |
Cléo Baras | b5125fa3d7 | |
Cléo Baras | 0f446fe0d3 | |
Cléo Baras | 5f656b431b | |
Cléo Baras | 83059cd995 | |
Cléo Baras | 8de1a44583 | |
Cléo Baras | 491d600bd4 | |
Emmanuel Viennet | 56aa5fbba3 | |
Cléo Baras | d6a75b176e | |
Emmanuel Viennet | e6d61fcd8a | |
Cléo Baras | 70f399e8b7 | |
Cléo Baras | 68bd20f8de | |
Cléo Baras | 1716daafde | |
Cléo Baras | 5e49384a90 | |
Cléo Baras | 828c619c74 | |
Cléo Baras | b8cb592ac9 | |
Cléo Baras | d8381884dc | |
Cléo Baras | 883028216f | |
Emmanuel Viennet | d140240909 | |
Cléo Baras | 267dbb6460 | |
Cléo Baras | 02a73de04d | |
Cléo Baras | e78a2d3ffe | |
Emmanuel Viennet | a200be586a | |
Emmanuel Viennet | 607604f91e | |
Emmanuel Viennet | 8eedac0f03 | |
Emmanuel Viennet | aea2204d9e | |
Emmanuel Viennet | 9c15cbe647 | |
Emmanuel Viennet | 6761f5a620 | |
Emmanuel Viennet | 69a53adb55 | |
Emmanuel Viennet | b30ea5f5fd | |
Emmanuel Viennet | 052fb3c7b9 | |
Lyanis Souidi | dbd0124c2c | |
Lyanis Souidi | e989a4ffa8 | |
Lyanis Souidi | 6ae2b0eb5f | |
Emmanuel Viennet | d7f3376103 | |
Lyanis Souidi | 677415fbfc | |
Emmanuel Viennet | bcb801662a | |
Emmanuel Viennet | 6cbeeedb1c | |
Emmanuel Viennet | 39e7ad3ad6 | |
Emmanuel Viennet | 177d38428e | |
Emmanuel Viennet | f4c1d00046 | |
Emmanuel Viennet | 86c12dee08 | |
Emmanuel Viennet | 8cf85f78a8 | |
Emmanuel Viennet | 9ec0ef27ba | |
Emmanuel Viennet | c8ac796347 | |
Cléo Baras | 2212990788 | |
Cléo Baras | 719d14673d | |
Cléo Baras | 98eb7699a0 | |
Cléo Baras | 7b22d26095 | |
Cléo Baras | 371d7eff64 | |
Cléo Baras | 0adcbb7c0b | |
Cléo Baras | f10d46c230 | |
Emmanuel Viennet | 4f41ef7050 | |
Emmanuel Viennet | ef4c2fa64b | |
Cléo Baras | be39245e25 | |
Cléo Baras | 196dbab298 | |
Emmanuel Viennet | 0594a659fa | |
Emmanuel Viennet | 072d013590 | |
Cléo Baras | 9c4e2627ba | |
Emmanuel Viennet | bbdf5da2e8 | |
Cléo Baras | 5828d4aaaf | |
Emmanuel Viennet | e6a544906e | |
Emmanuel Viennet | bacd734ab5 | |
Emmanuel Viennet | e611fa4bfc | |
Emmanuel Viennet | 128b282186 | |
Emmanuel Viennet | 57d36927ac | |
Emmanuel Viennet | d5fdd5b8b8 | |
Emmanuel Viennet | 7162d83f39 | |
Emmanuel Viennet | 7805a6cab9 | |
Emmanuel Viennet | 2c840b7803 | |
Emmanuel Viennet | 0645db8ab0 | |
Emmanuel Viennet | 9e13b51669 | |
Emmanuel Viennet | 034800ab9a | |
Emmanuel Viennet | 4b2e88c678 | |
Cléo Baras | c9af2345fb | |
Cléo Baras | 0bf0311f2f | |
Emmanuel Viennet | 5bbdc567f3 | |
Emmanuel Viennet | 027f11e494 | |
Emmanuel Viennet | ef171364a6 | |
Cléo Baras | 9b9d7b611b | |
Cléo Baras | 02bfb626cb | |
Cléo Baras | 597a28f86d | |
Emmanuel Viennet | 2915f4e981 | |
Emmanuel Viennet | af659d5f09 | |
Emmanuel Viennet | 838ae7cf7e | |
Emmanuel Viennet | e4c8637c41 | |
Emmanuel Viennet | 0bf3c22cd0 | |
Cléo Baras | 6700687e96 | |
Cléo Baras | b8e20b6be8 | |
Cléo Baras | 78eeb9c67f | |
Cléo Baras | 66fbb0afbc | |
Cléo Baras | 387af40b65 | |
Emmanuel Viennet | 952132695f | |
Emmanuel Viennet | 0b6a4b5c7e | |
Emmanuel Viennet | 556725b3ef | |
Emmanuel Viennet | 90bf31fc03 | |
Emmanuel Viennet | f7e41dc7fe | |
Emmanuel Viennet | eefbe70944 | |
Emmanuel Viennet | 5446ac0ed2 | |
Emmanuel Viennet | 1f6f3620a2 | |
Emmanuel Viennet | 04d1fbe272 | |
Emmanuel Viennet | c270c24c5b | |
Emmanuel Viennet | 8b751608e1 | |
Emmanuel Viennet | 0fb45fc9ca | |
Emmanuel Viennet | 8652ef2e7b | |
Emmanuel Viennet | 9be77e4f37 | |
Emmanuel Viennet | a00e2da461 | |
Emmanuel Viennet | 3481f7c1c2 | |
Emmanuel Viennet | 64d7e1ed42 | |
Cléo Baras | d310304e9e | |
Cléo Baras | fce23aa066 | |
Cléo Baras | 9c6d988fc3 | |
Cléo Baras | cb5df2fffd | |
Cléo Baras | 3550e4290a | |
Emmanuel Viennet | 787e514dca | |
Emmanuel Viennet | e25f7d4fc9 | |
Cléo Baras | f87902d1ac | |
Emmanuel Viennet | 39b3cd9e05 | |
Emmanuel Viennet | d1074a8227 | |
Cléo Baras | 1b18034adb | |
Cléo Baras | 871f5c1d61 | |
Emmanuel Viennet | 79f07deac0 | |
Emmanuel Viennet | 431dd20911 | |
Emmanuel Viennet | 4985182b9a | |
Emmanuel Viennet | 4681294cb8 | |
Emmanuel Viennet | f6051f930f | |
Cléo Baras | cf415763b3 | |
Cléo Baras | 769f6c0ea0 | |
Emmanuel Viennet | 33f2afb04b | |
Cléo Baras | 02bccb58aa | |
Emmanuel Viennet | 4f7da8bfa4 | |
Emmanuel Viennet | a439c4c985 | |
Emmanuel Viennet | d991eb007c | |
Emmanuel Viennet | 4f10d017be | |
Cléo Baras | be7bb588cf | |
Cléo Baras | 83c6ec44c8 | |
Emmanuel Viennet | 3a3d47ebe4 | |
Emmanuel Viennet | 54be507e35 | |
Cléo Baras | efd735542e | |
Cléo Baras | 776b0fb228 | |
Cléo Baras | cd8d73b41f | |
Cléo Baras | decc28b896 | |
Emmanuel Viennet | a4b25eb47b | |
Emmanuel Viennet | 790ba910ee | |
Cléo Baras | 82713752c2 | |
Emmanuel Viennet | fc35974951 | |
Cléo Baras | 283daae4d9 | |
Emmanuel Viennet | ece689eb10 | |
Emmanuel Viennet | 242771c619 | |
Emmanuel Viennet | aa45680ed8 | |
Emmanuel Viennet | 086b8ee191 | |
Cléo Baras | 8477dc96ca | |
Cléo Baras | e3cde87a0f | |
Cléo Baras | 8b3efe9dad | |
Iziram | dfbe0dc3ed | |
Cléo Baras | 02976c9996 | |
Emmanuel Viennet | 2a239ab92f | |
Emmanuel Viennet | 9989f419cb | |
Emmanuel Viennet | 74b8b90a65 | |
Emmanuel Viennet | 2ad77428a5 | |
Emmanuel Viennet | 505f5e5f1c | |
Emmanuel Viennet | a7848f0a4e | |
Emmanuel Viennet | 2660801dd5 | |
Emmanuel Viennet | e415a5255e | |
Emmanuel Viennet | dae04658b7 | |
Emmanuel Viennet | f842fa0b4f | |
Emmanuel Viennet | 45c685d725 | |
Emmanuel Viennet | ef63e27aed | |
Iziram | a04278f301 | |
Emmanuel Viennet | a12505e8df | |
Iziram | 32a4ada483 | |
Emmanuel Viennet | 555e8af818 | |
Emmanuel Viennet | f09b2028e2 | |
Emmanuel Viennet | 7d2d5a3ea9 | |
Emmanuel Viennet | a65c1d3c4a | |
Emmanuel Viennet | b8eb8bb77f | |
Emmanuel Viennet | 4917034b6d | |
Emmanuel Viennet | 81915b1522 | |
Emmanuel Viennet | c6910fc76e | |
Cléo Baras | 90c2516d01 | |
Emmanuel Viennet | aee4f14b81 | |
Cléo Baras | 340aa749b2 | |
Cléo Baras | 7a0b560d54 | |
Cléo Baras | 9e925aa500 | |
Emmanuel Viennet | ff63a32bbe | |
Emmanuel Viennet | ab116ee9e7 | |
Emmanuel Viennet | b91609950c | |
Emmanuel Viennet | f2f229df4a | |
Emmanuel Viennet | 6908b0b8d2 | |
Emmanuel Viennet | 706b21ede7 | |
Emmanuel Viennet | 238fbe887c | |
Cléo Baras | 3e55391f7e | |
Emmanuel Viennet | 9c1c316f14 | |
Cléo Baras | c9336dd01c | |
Iziram | 87e98b5478 | |
Iziram | 7659bcb488 | |
Iziram | 44de81857a | |
Iziram | 78d97d2c2d | |
Iziram | 4b304c559b | |
Emmanuel Viennet | 21eeff90aa | |
Emmanuel Viennet | 6d3f276cc0 | |
Emmanuel Viennet | e2ca673239 | |
Emmanuel Viennet | f55f3fe82f | |
Emmanuel Viennet | 9104a8986e | |
Iziram | 3e1f563ecd | |
Cléo Baras | df20372abb | |
Emmanuel Viennet | 0cafc0b184 | |
Emmanuel Viennet | 2c42a1547c | |
Emmanuel Viennet | 7ce57d28cb | |
Emmanuel Viennet | e3fc13f215 | |
Iziram | 3a3f94b7cf | |
Iziram | 023e3a4c04 | |
Emmanuel Viennet | 76bedfb303 | |
Emmanuel Viennet | 0634dbd0aa | |
Cléo Baras | 01d0f7d651 | |
Cléo Baras | 6d16927db9 | |
Cléo Baras | 2f81ce8ac2 | |
Cléo Baras | 898270d2f0 | |
Cléo Baras | e28bfa34be | |
Cléo Baras | 86e8803c87 | |
Emmanuel Viennet | 4babffd022 | |
Emmanuel Viennet | 6461d14883 | |
Emmanuel Viennet | 524df5cbc8 | |
Iziram | 4d19d385f1 | |
Iziram | c639778b78 | |
Iziram | 7d441b1c4d | |
Iziram | 0fa35708f9 | |
Iziram | bcb01089ca | |
Iziram | a05801b78a | |
Emmanuel Viennet | a90fd6dcd0 | |
Emmanuel Viennet | ac9b5722cf | |
Emmanuel Viennet | e61ec5e04e | |
Iziram | 5bb4e4e0eb | |
Emmanuel Viennet | 1e33626b60 | |
Iziram | a63ed6c0ef | |
Iziram | 943604996b | |
Iziram | ff92a8f61e | |
Emmanuel Viennet | 8e74a143fa | |
Emmanuel Viennet | c8ac59d8da | |
Emmanuel Viennet | 4f07da0b41 | |
Emmanuel Viennet | f2b6e7f253 | |
Emmanuel Viennet | bd860295ba | |
Emmanuel Viennet | 276ba50576 | |
Emmanuel Viennet | 2a4fdf8b84 | |
Emmanuel Viennet | 564d766087 | |
Emmanuel Viennet | 0f5176b553 | |
Emmanuel Viennet | fb62904cc9 | |
Iziram | 5af3e8d14d | |
Iziram | e0ca0100d0 | |
Emmanuel Viennet | 6423baa34b | |
Emmanuel Viennet | 96aaca9746 | |
Emmanuel Viennet | 85f0323a80 | |
Emmanuel Viennet | 9987a26d9e | |
Emmanuel Viennet | 1a5072a35c | |
Emmanuel Viennet | 97eb18361f | |
Emmanuel Viennet | f7d16900b1 | |
Emmanuel Viennet | 97ec0524c4 | |
Emmanuel Viennet | 7da8793d29 | |
Sébastien Lehmann | f7a99d34b2 | |
Emmanuel Viennet | ae94d8fba4 | |
Emmanuel Viennet | a6448192a6 | |
Sébastien Lehmann | c88464125e | |
Emmanuel Viennet | 397e3acea2 | |
Emmanuel Viennet | d3b1aaabd8 | |
Emmanuel Viennet | 2b9b459106 | |
Emmanuel Viennet | 924037d5c6 | |
Emmanuel Viennet | a1e689d105 | |
Emmanuel Viennet | a49437fa47 | |
Emmanuel Viennet | 999757dd77 | |
Sébastien Lehmann | e11101b53b | |
Sébastien Lehmann | 2ac442315c | |
Sébastien Lehmann | 967c8a91c5 |
|
@ -176,3 +176,6 @@ copy
|
|||
|
||||
# Symlinks static ScoDoc
|
||||
app/static/links/[0-9]*.*[0-9]
|
||||
|
||||
# Essais locaux
|
||||
xp/
|
||||
|
|
|
@ -315,12 +315,6 @@ def create_app(config_class=DevConfig):
|
|||
app.register_error_handler(503, postgresql_server_error)
|
||||
app.register_error_handler(APIInvalidParams, handle_invalid_usage)
|
||||
|
||||
# Add some globals
|
||||
# previously in Flask-Bootstrap:
|
||||
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
|
||||
field, HiddenField
|
||||
)
|
||||
|
||||
from app.auth import bp as auth_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/auth")
|
||||
|
@ -338,8 +332,15 @@ def create_app(config_class=DevConfig):
|
|||
from app.api import api_bp
|
||||
from app.api import api_web_bp
|
||||
|
||||
# Jinja2 configuration
|
||||
# Enable autoescaping of all templates, including .j2
|
||||
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
# previously in Flask-Bootstrap:
|
||||
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
|
||||
field, HiddenField
|
||||
)
|
||||
|
||||
# https://scodoc.fr/ScoDoc
|
||||
app.register_blueprint(scodoc_bp)
|
||||
|
|
|
@ -3,9 +3,11 @@
|
|||
from flask_json import as_json
|
||||
from flask import Blueprint
|
||||
from flask import request, g
|
||||
from flask_login import current_user
|
||||
from app import db
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import AccessDenied, ScoException
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
api_web_bp = Blueprint("apiweb", __name__)
|
||||
|
@ -48,20 +50,35 @@ def requested_format(default_format="json", allowed_formats=None):
|
|||
|
||||
|
||||
@as_json
|
||||
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
|
||||
def get_model_api_object(
|
||||
model_cls: db.Model,
|
||||
model_id: int,
|
||||
join_cls: db.Model = None,
|
||||
restrict: bool | None = None,
|
||||
):
|
||||
"""
|
||||
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
|
||||
|
||||
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls
|
||||
|
||||
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
|
||||
|
||||
L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte
|
||||
(sans données personnelles, ou sans informations sur le justificatif d'absence)
|
||||
"""
|
||||
query = model_cls.query.filter_by(id=model_id)
|
||||
if g.scodoc_dept and join_cls is not None:
|
||||
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
|
||||
unique: model_cls = query.first_or_404()
|
||||
unique: model_cls = query.first()
|
||||
|
||||
return unique.to_dict(format_api=True)
|
||||
if unique is None:
|
||||
return scu.json_error(
|
||||
404,
|
||||
message=f"{model_cls.__name__} inexistant(e)",
|
||||
)
|
||||
if restrict is None:
|
||||
return unique.to_dict(format_api=True)
|
||||
return unique.to_dict(format_api=True, restrict=restrict)
|
||||
|
||||
|
||||
from app.api import tokens
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Assiduités
|
||||
"""
|
||||
"""ScoDoc 9 API : Assiduités"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user, login_required
|
||||
from flask_sqlalchemy.query import Query
|
||||
from sqlalchemy.orm.exc import ObjectDeletedError
|
||||
|
||||
from app import db, log, set_sco_dept
|
||||
import app.scodoc.sco_assiduites as scass
|
||||
|
@ -39,6 +40,7 @@ from app.scodoc.sco_utils import json_error
|
|||
@api_web_bp.route("/assiduite/<int:assiduite_id>")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def assiduite(assiduite_id: int = None):
|
||||
"""Retourne un objet assiduité à partir de son id
|
||||
|
||||
|
@ -172,6 +174,7 @@ def count_assiduites(
|
|||
404,
|
||||
message="étudiant inconnu",
|
||||
)
|
||||
set_sco_dept(etud.departement.acronym)
|
||||
|
||||
# Les filtres qui seront appliqués au comptage (type, date, etudid...)
|
||||
filtered: dict[str, object] = {}
|
||||
|
@ -335,7 +338,7 @@ def assiduites_group(with_query: bool = False):
|
|||
try:
|
||||
etuds = [int(etu) for etu in etuds]
|
||||
except ValueError:
|
||||
return json_error(404, "Le champs etudids n'est pas correctement formé")
|
||||
return json_error(404, "Le champ etudids n'est pas correctement formé")
|
||||
|
||||
# Vérification que tous les étudiants sont du même département
|
||||
query = Identite.query.filter(Identite.id.in_(etuds))
|
||||
|
@ -444,6 +447,8 @@ def count_assiduites_formsemestre(
|
|||
if formsemestre is None:
|
||||
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
|
||||
|
||||
set_sco_dept(formsemestre.departement.acronym)
|
||||
|
||||
# Récupération des étudiants du formsemestre
|
||||
etuds = formsemestre.etuds.all()
|
||||
etuds_id = [etud.id for etud in etuds]
|
||||
|
@ -833,9 +838,9 @@ def assiduite_edit(assiduite_id: int):
|
|||
"""
|
||||
|
||||
# Récupération de l'assiduité à modifier
|
||||
assiduite_unique: Assiduite = Assiduite.query.filter_by(
|
||||
id=assiduite_id
|
||||
).first_or_404()
|
||||
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
|
||||
if assiduite_unique is None:
|
||||
return json_error(404, "Assiduité non existante")
|
||||
# Récupération des valeurs à modifier
|
||||
data = request.get_json(force=True)
|
||||
|
||||
|
@ -854,7 +859,10 @@ def assiduite_edit(assiduite_id: int):
|
|||
msg=f"assiduite: modif {assiduite_unique}",
|
||||
)
|
||||
db.session.commit()
|
||||
scass.simple_invalidate_cache(assiduite_unique.to_dict())
|
||||
try:
|
||||
scass.simple_invalidate_cache(assiduite_unique.to_dict())
|
||||
except ObjectDeletedError:
|
||||
return json_error(404, "Assiduité supprimée / inexistante")
|
||||
|
||||
return {"OK": True}
|
||||
|
||||
|
@ -1231,8 +1239,8 @@ def _filter_manager(requested, assiduites_query: Query) -> Query:
|
|||
annee: int = scu.annee_scolaire()
|
||||
|
||||
assiduites_query: Query = assiduites_query.filter(
|
||||
Assiduite.date_debut >= scu.date_debut_anne_scolaire(annee),
|
||||
Assiduite.date_fin <= scu.date_fin_anne_scolaire(annee),
|
||||
Assiduite.date_debut >= scu.date_debut_annee_scolaire(annee),
|
||||
Assiduite.date_fin <= scu.date_fin_annee_scolaire(annee),
|
||||
)
|
||||
|
||||
return assiduites_query
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -295,7 +295,7 @@ def dept_formsemestres_courants_by_id(dept_id: int):
|
|||
if date_courante:
|
||||
test_date = datetime.fromisoformat(date_courante)
|
||||
else:
|
||||
test_date = app.db.func.now()
|
||||
test_date = db.func.current_date()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
|||
from datetime import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, request
|
||||
from flask import g, request, Response
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
|
@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_
|
|||
from sqlalchemy.dialects.postgresql import VARCHAR
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import tools
|
||||
from app.but import bulletin_but_court
|
||||
|
@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required
|
|||
from app.models import (
|
||||
Admission,
|
||||
Departement,
|
||||
EtudAnnotation,
|
||||
FormSemestreInscription,
|
||||
FormSemestre,
|
||||
Identite,
|
||||
|
@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu
|
|||
#
|
||||
|
||||
|
||||
def _get_etud_by_code(
|
||||
code_type: str, code: str, dept: Departement
|
||||
) -> tuple[bool, Response | Identite]:
|
||||
"""Get etud, using etudid, NIP or INE
|
||||
Returns True, etud if ok, or False, error response.
|
||||
"""
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return False, json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code)
|
||||
else:
|
||||
return False, json_error(404, "invalid code_type")
|
||||
if dept:
|
||||
query = query.filter_by(dept_id=dept.id)
|
||||
etud = query.first()
|
||||
if etud is None:
|
||||
return False, json_error(404, message="etudiant inexistant")
|
||||
return True, etud
|
||||
|
||||
|
||||
@bp.route("/etudiants/courants", defaults={"long": False})
|
||||
@bp.route("/etudiants/courants/long", defaults={"long": True})
|
||||
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
|
||||
|
@ -104,7 +131,10 @@ def etudiants_courants(long=False):
|
|||
or_(Departement.acronym == acronym for acronym in allowed_depts)
|
||||
)
|
||||
if long:
|
||||
data = [etud.to_dict_api() for etud in etuds]
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
data = [
|
||||
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
|
||||
]
|
||||
else:
|
||||
data = [etud.to_dict_short() for etud in etuds]
|
||||
return data
|
||||
|
@ -138,8 +168,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
|
|||
404,
|
||||
message="étudiant inconnu",
|
||||
)
|
||||
|
||||
return etud.to_dict_api()
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
return etud.to_dict_api(restrict=restrict, with_annotations=True)
|
||||
|
||||
|
||||
@bp.route("/etudiant/etudid/<int:etudid>/photo")
|
||||
|
@ -251,7 +281,10 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
|
|||
query = query.join(Departement).filter(
|
||||
or_(Departement.acronym == acronym for acronym in allowed_depts)
|
||||
)
|
||||
return [etud.to_dict_api() for etud in query]
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
return [
|
||||
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
|
||||
]
|
||||
|
||||
|
||||
@bp.route("/etudiants/name/<string:start>")
|
||||
|
@ -278,7 +311,11 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
|
|||
)
|
||||
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
|
||||
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
|
||||
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
return [
|
||||
etud.to_dict_api(restrict=restrict)
|
||||
for etud in sorted(etuds, key=attrgetter("sort_key"))
|
||||
]
|
||||
|
||||
|
||||
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
|
||||
|
@ -377,30 +414,24 @@ def bulletin(
|
|||
if version == "pdf":
|
||||
version = "long"
|
||||
pdf = True
|
||||
if version not in scu.BULLETINS_VERSIONS_BUT:
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if version not in (
|
||||
scu.BULLETINS_VERSIONS_BUT
|
||||
if formsemestre.formation.is_apc()
|
||||
else scu.BULLETINS_VERSIONS
|
||||
):
|
||||
return json_error(404, "version invalide")
|
||||
# return f"{code_type}={code}, version={version}, pdf={pdf}"
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
if formsemestre.bul_hide_xml and pdf:
|
||||
return json_error(403, "bulletin non disponible")
|
||||
# note: la version json est réduite si bul_hide_xml
|
||||
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
|
||||
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
|
||||
return json_error(404, "formsemestre inexistant")
|
||||
app.set_sco_dept(dept.acronym)
|
||||
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
|
||||
else:
|
||||
return json_error(404, "invalid code_type")
|
||||
etud = query.first()
|
||||
if etud is None:
|
||||
return json_error(404, message="etudiant inexistant")
|
||||
ok, etud = _get_etud_by_code(code_type, code, dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
|
||||
if version == "butcourt":
|
||||
if pdf:
|
||||
|
@ -543,7 +574,8 @@ def etudiant_create(force=False):
|
|||
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
|
||||
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
|
||||
db.session.refresh(etud)
|
||||
r = etud.to_dict_api()
|
||||
|
||||
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
|
||||
return r
|
||||
|
||||
|
||||
|
@ -551,26 +583,15 @@ def etudiant_create(force=False):
|
|||
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
|
||||
@scodoc
|
||||
@permission_required(Permission.EtudInscrit)
|
||||
@as_json
|
||||
def etudiant_edit(
|
||||
code_type: str = "etudid",
|
||||
code: str = None,
|
||||
):
|
||||
"""Edition des données étudiant (identité, admission, adresses)"""
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code)
|
||||
else:
|
||||
return json_error(404, "invalid code_type")
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
etud: Identite = query.first()
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
#
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
etud.from_dict(args)
|
||||
|
@ -590,5 +611,70 @@ def etudiant_edit(
|
|||
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
|
||||
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
|
||||
db.session.refresh(etud)
|
||||
r = etud.to_dict_api()
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
r = etud.to_dict_api(restrict=restrict)
|
||||
return r
|
||||
|
||||
|
||||
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
|
||||
@api_web_bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
|
||||
)
|
||||
@scodoc
|
||||
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
|
||||
@as_json
|
||||
def etudiant_annotation(
|
||||
code_type: str = "etudid",
|
||||
code: str = None,
|
||||
):
|
||||
"""Ajout d'une annotation sur un étudiant"""
|
||||
if not current_user.has_permission(Permission.ViewEtudData):
|
||||
return json_error(403, "non autorisé (manque ViewEtudData)")
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
#
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
comment = args.get("comment", None)
|
||||
if not isinstance(comment, str):
|
||||
return json_error(404, "invalid comment (expected string)")
|
||||
if len(comment) > scu.MAX_TEXT_LEN:
|
||||
return json_error(404, "invalid comment (too large)")
|
||||
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
|
||||
etud.annotations.append(annotation)
|
||||
db.session.add(etud)
|
||||
db.session.commit()
|
||||
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
|
||||
return annotation.to_dict()
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
|
||||
methods=["POST"],
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
|
||||
methods=["POST"],
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@as_json
|
||||
@permission_required(Permission.EtudInscrit)
|
||||
def etudiant_annotation_delete(
|
||||
code_type: str = "etudid", code: str = None, annotation_id: int = None
|
||||
):
|
||||
"""
|
||||
Suppression d'une annotation
|
||||
"""
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
annotation = EtudAnnotation.query.filter_by(
|
||||
etudid=etud.id, id=annotation_id
|
||||
).first()
|
||||
if annotation is None:
|
||||
return json_error(404, "annotation not found")
|
||||
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
|
||||
db.session.delete(annotation)
|
||||
db.session.commit()
|
||||
return "ok"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -67,7 +67,7 @@ def get_evaluation(evaluation_id: int):
|
|||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def evaluations(moduleimpl_id: int):
|
||||
def moduleimpl_evaluations(moduleimpl_id: int):
|
||||
"""
|
||||
Retourne la liste des évaluations d'un moduleimpl
|
||||
|
||||
|
@ -75,14 +75,8 @@ def evaluations(moduleimpl_id: int):
|
|||
|
||||
Exemple de résultat : voir /evaluation
|
||||
"""
|
||||
query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
|
||||
if g.scodoc_dept:
|
||||
query = (
|
||||
query.join(ModuleImpl)
|
||||
.join(FormSemestre)
|
||||
.filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
return [e.to_dict_api() for e in query]
|
||||
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
|
||||
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations]
|
||||
|
||||
|
||||
@bp.route("/evaluation/<int:evaluation_id>/notes")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -52,7 +52,8 @@ def formations():
|
|||
@as_json
|
||||
def formations_ids():
|
||||
"""
|
||||
Retourne la liste de toutes les id de formations (tous départements)
|
||||
Retourne la liste de toutes les id de formations
|
||||
(tous départements, ou du département indiqué dans la route)
|
||||
|
||||
Exemple de résultat : [ 17, 99, 32 ]
|
||||
"""
|
||||
|
@ -328,6 +329,8 @@ def desassoc_ue_niveau(ue_id: int):
|
|||
ue.niveau_competence = None
|
||||
db.session.add(ue)
|
||||
db.session.commit()
|
||||
# Invalidation du cache
|
||||
ue.formation.invalidate_cached_sems()
|
||||
log(f"desassoc_ue_niveau: {ue}")
|
||||
if g.scodoc_dept:
|
||||
# "usage web"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -11,8 +11,8 @@ from operator import attrgetter, itemgetter
|
|||
|
||||
from flask import g, make_response, request
|
||||
from flask_json import as_json
|
||||
from flask_login import login_required
|
||||
|
||||
from flask_login import current_user, login_required
|
||||
import sqlalchemy as sa
|
||||
import app
|
||||
from app import db
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
|
@ -38,7 +38,7 @@ from app.scodoc import sco_groups
|
|||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.tables.recap import TableRecap
|
||||
from app.tables.recap import TableRecap, RowRecap
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>")
|
||||
|
@ -124,8 +124,8 @@ def formsemestres_query():
|
|||
annee_scolaire_int = int(annee_scolaire)
|
||||
except ValueError:
|
||||
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
|
||||
debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
|
||||
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
|
||||
debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int)
|
||||
fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int)
|
||||
formsemestres = formsemestres.filter(
|
||||
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee
|
||||
)
|
||||
|
@ -171,6 +171,44 @@ def formsemestres_query():
|
|||
]
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
|
||||
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
|
||||
@scodoc
|
||||
@permission_required(Permission.EditFormSemestre)
|
||||
@as_json
|
||||
def formsemestre_edit(formsemestre_id: int):
|
||||
"""Modifie les champs d'un formsemestre."""
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
editable_keys = {
|
||||
"semestre_id",
|
||||
"titre",
|
||||
"date_debut",
|
||||
"date_fin",
|
||||
"edt_id",
|
||||
"etat",
|
||||
"modalite",
|
||||
"gestion_compensation",
|
||||
"bul_hide_xml",
|
||||
"block_moyennes",
|
||||
"block_moyenne_generale",
|
||||
"mode_calcul_moyennes",
|
||||
"gestion_semestrielle",
|
||||
"bul_bgcolor",
|
||||
"resp_can_edit",
|
||||
"resp_can_change_ens",
|
||||
"ens_can_edit_eval",
|
||||
"elt_sem_apo",
|
||||
"elt_annee_apo",
|
||||
}
|
||||
formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys})
|
||||
try:
|
||||
db.session.commit()
|
||||
except sa.exc.StatementError as exc:
|
||||
return json_error(404, f"invalid argument(s): {exc.args[0]}")
|
||||
return formsemestre.to_dict_api()
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
|
||||
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
|
||||
|
@ -360,7 +398,8 @@ def formsemestre_etudiants(
|
|||
inscriptions = formsemestre.inscriptions
|
||||
|
||||
if long:
|
||||
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
|
||||
else:
|
||||
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
|
||||
# Ajout des groupes de chaque étudiants
|
||||
|
@ -425,7 +464,7 @@ def etat_evals(formsemestre_id: int):
|
|||
for modimpl_id in nt.modimpls_results:
|
||||
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
|
||||
modimpl_dict = modimpl.to_dict(convert_objects=True)
|
||||
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
|
||||
|
||||
list_eval = []
|
||||
for evaluation_id in modimpl_results.evaluations_etat:
|
||||
|
@ -467,13 +506,13 @@ def etat_evals(formsemestre_id: int):
|
|||
date_mediane = notes_sorted[len(notes_sorted) // 2].date
|
||||
|
||||
eval_dict["saisie_notes"] = {
|
||||
"datetime_debut": date_debut.isoformat()
|
||||
if date_debut is not None
|
||||
else None,
|
||||
"datetime_debut": (
|
||||
date_debut.isoformat() if date_debut is not None else None
|
||||
),
|
||||
"datetime_fin": date_fin.isoformat() if date_fin is not None else None,
|
||||
"datetime_mediane": date_mediane.isoformat()
|
||||
if date_mediane is not None
|
||||
else None,
|
||||
"datetime_mediane": (
|
||||
date_mediane.isoformat() if date_mediane is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
list_eval.append(eval_dict)
|
||||
|
@ -504,16 +543,30 @@ def formsemestre_resultat(formsemestre_id: int):
|
|||
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
|
||||
table = TableRecap(
|
||||
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
|
||||
)
|
||||
# Supprime les champs inutiles (mise en forme)
|
||||
rows = table.to_list()
|
||||
# Ajoute le groupe de chaque partition:
|
||||
# Ajoute le groupe de chaque partition,
|
||||
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
|
||||
for row in rows:
|
||||
row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
|
||||
class RowRecapAPI(RowRecap):
|
||||
"""Pour table avec partitions et sort_key"""
|
||||
|
||||
def add_etud_cols(self):
|
||||
"""Ajoute colonnes étudiant: codes, noms"""
|
||||
super().add_etud_cols()
|
||||
self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {}))
|
||||
self.add_cell("sort_key", "sort_key", self.etud.sort_key)
|
||||
|
||||
table = TableRecap(
|
||||
res,
|
||||
convert_values=convert_values,
|
||||
include_evaluations=False,
|
||||
mode_jury=False,
|
||||
row_class=RowRecapAPI,
|
||||
)
|
||||
|
||||
rows = table.to_list()
|
||||
|
||||
# for row in rows:
|
||||
# row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
return rows
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -66,7 +66,7 @@ def _news_delete_jury_etud(etud: Identite):
|
|||
"génère news sur effacement décision"
|
||||
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
|
||||
url = url_for(
|
||||
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
|
||||
"scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id
|
||||
)
|
||||
ScolarNews.add(
|
||||
typ=ScolarNews.NEWS_JURY,
|
||||
|
|
|
@ -15,19 +15,13 @@ from werkzeug.exceptions import NotFound
|
|||
|
||||
import app.scodoc.sco_assiduites as scass
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import db
|
||||
from app import db, set_sco_dept
|
||||
from app.api import api_bp as bp
|
||||
from app.api import api_web_bp
|
||||
from app.api import get_model_api_object, tools
|
||||
from app.decorators import permission_required, scodoc
|
||||
from app.models import (
|
||||
Identite,
|
||||
Justificatif,
|
||||
Departement,
|
||||
FormSemestre,
|
||||
)
|
||||
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
|
||||
from app.models.assiduites import (
|
||||
compute_assiduites_justified,
|
||||
get_formsemestre_from_data,
|
||||
)
|
||||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
|
@ -53,14 +47,19 @@ def justificatif(justif_id: int = None):
|
|||
"date_fin": "2022-10-31T10:00+01:00",
|
||||
"etat": "valide",
|
||||
"fichier": "archive_id",
|
||||
"raison": "une raison",
|
||||
"raison": "une raison", // VIDE si pas le droit
|
||||
"entry_date": "2022-10-31T08:00+01:00",
|
||||
"user_id": 1 or null,
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
return get_model_api_object(Justificatif, justif_id, Identite)
|
||||
return get_model_api_object(
|
||||
Justificatif,
|
||||
justif_id,
|
||||
Identite,
|
||||
restrict=not current_user.has_permission(Permission.AbsJustifView),
|
||||
)
|
||||
|
||||
|
||||
# etudid
|
||||
|
@ -133,8 +132,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
|
|||
|
||||
# Mise en forme des données puis retour en JSON
|
||||
data_set: list[dict] = []
|
||||
restrict = not current_user.has_permission(Permission.AbsJustifView)
|
||||
for just in justificatifs_query.all():
|
||||
data = just.to_dict(format_api=True)
|
||||
data = just.to_dict(format_api=True, restrict=restrict)
|
||||
data_set.append(data)
|
||||
|
||||
return data_set
|
||||
|
@ -151,10 +151,15 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
|
|||
@as_json
|
||||
@permission_required(Permission.ScoView)
|
||||
def justificatifs_dept(dept_id: int = None, with_query: bool = False):
|
||||
"""XXX TODO missing doc"""
|
||||
"""
|
||||
Renvoie tous les justificatifs d'un département
|
||||
(en ajoutant un champ "formsemestre" si possible)
|
||||
"""
|
||||
|
||||
# Récupération du département et des étudiants du département
|
||||
dept: Departement = Departement.query.get_or_404(dept_id)
|
||||
dept: Departement = Departement.query.get(dept_id)
|
||||
if dept is None:
|
||||
return json_error(404, "Assiduité non existante")
|
||||
etuds: list[int] = [etud.id for etud in dept.etudiants]
|
||||
|
||||
# Récupération des justificatifs des étudiants du département
|
||||
|
@ -167,14 +172,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
|
|||
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
|
||||
|
||||
# Mise en forme des données et retour JSON
|
||||
restrict = not current_user.has_permission(Permission.AbsJustifView)
|
||||
data_set: list[dict] = []
|
||||
for just in justificatifs_query:
|
||||
data_set.append(_set_sems(just))
|
||||
data_set.append(_set_sems(just, restrict=restrict))
|
||||
|
||||
return data_set
|
||||
|
||||
|
||||
def _set_sems(justi: Justificatif) -> dict:
|
||||
def _set_sems(justi: Justificatif, restrict: bool) -> dict:
|
||||
"""
|
||||
_set_sems Ajoute le formsemestre associé au justificatif s'il existe
|
||||
|
||||
|
@ -187,7 +193,7 @@ def _set_sems(justi: Justificatif) -> dict:
|
|||
dict: La représentation de l'assiduité en dictionnaire
|
||||
"""
|
||||
# Conversion du justificatif en dictionnaire
|
||||
data = justi.to_dict(format_api=True)
|
||||
data = justi.to_dict(format_api=True, restrict=restrict)
|
||||
|
||||
# Récupération du formsemestre de l'assiduité
|
||||
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
|
||||
|
@ -241,9 +247,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
|
|||
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
|
||||
|
||||
# Retour des justificatifs en JSON
|
||||
restrict = not current_user.has_permission(Permission.AbsJustifView)
|
||||
data_set: list[dict] = []
|
||||
for justi in justificatifs_query.all():
|
||||
data = justi.to_dict(format_api=True)
|
||||
data = justi.to_dict(format_api=True, restrict=restrict)
|
||||
data_set.append(data)
|
||||
|
||||
return data_set
|
||||
|
@ -292,6 +299,7 @@ def justif_create(etudid: int = None, nip=None, ine=None):
|
|||
404,
|
||||
message="étudiant inconnu",
|
||||
)
|
||||
set_sco_dept(etud.departement.acronym)
|
||||
|
||||
# Récupération des justificatifs à créer
|
||||
create_list: list[object] = request.get_json(force=True)
|
||||
|
@ -301,7 +309,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
|
|||
|
||||
errors: list[dict] = []
|
||||
success: list[dict] = []
|
||||
justifs: list[Justificatif] = []
|
||||
|
||||
# énumération des justificatifs
|
||||
for i, data in enumerate(create_list):
|
||||
|
@ -313,11 +320,9 @@ def justif_create(etudid: int = None, nip=None, ine=None):
|
|||
errors.append({"indice": i, "message": obj})
|
||||
else:
|
||||
success.append({"indice": i, "message": obj})
|
||||
justifs.append(justi)
|
||||
justi.justifier_assiduites()
|
||||
scass.simple_invalidate_cache(data, etud.id)
|
||||
|
||||
# Actualisation des assiduités justifiées en fonction de tous les nouveaux justificatifs
|
||||
compute_assiduites_justified(etud.etudid, justifs)
|
||||
return {"errors": errors, "success": success}
|
||||
|
||||
|
||||
|
@ -486,9 +491,16 @@ def justif_edit(justif_id: int):
|
|||
return json_error(404, err)
|
||||
|
||||
# Mise à jour du justificatif
|
||||
justificatif_unique.dejustifier_assiduites()
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
Scolog.logdb(
|
||||
method="edit_justificatif",
|
||||
etudid=justificatif_unique.etudiant.id,
|
||||
msg=f"justificatif modif: {justificatif_unique}",
|
||||
)
|
||||
|
||||
# Génération du dictionnaire de retour
|
||||
# La couverture correspond
|
||||
# - aux assiduités précédemment justifiées par le justificatif
|
||||
|
@ -496,11 +508,7 @@ def justif_edit(justif_id: int):
|
|||
retour = {
|
||||
"couverture": {
|
||||
"avant": avant_ids,
|
||||
"apres": compute_assiduites_justified(
|
||||
justificatif_unique.etudid,
|
||||
[justificatif_unique],
|
||||
True,
|
||||
),
|
||||
"apres": justificatif_unique.justifier_assiduites(),
|
||||
}
|
||||
}
|
||||
# Invalide le cache
|
||||
|
@ -577,14 +585,10 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
|
|||
|
||||
# On invalide le cache
|
||||
scass.simple_invalidate_cache(justificatif_unique.to_dict())
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
justificatif_unique.dejustifier_assiduites()
|
||||
# On supprime le justificatif
|
||||
db.session.delete(justificatif_unique)
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
compute_assiduites_justified(
|
||||
justificatif_unique.etudid,
|
||||
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
|
||||
True,
|
||||
)
|
||||
|
||||
return (200, "OK")
|
||||
|
||||
|
@ -685,7 +689,6 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
|
|||
@as_json
|
||||
@permission_required(Permission.AbsChange)
|
||||
def justif_remove(justif_id: int = None):
|
||||
# XXX TODO pas de test unitaire
|
||||
"""
|
||||
Supression d'un fichier ou d'une archive
|
||||
{
|
||||
|
@ -874,8 +877,8 @@ def _filter_manager(requested, justificatifs_query: Query):
|
|||
annee: int = scu.annee_scolaire()
|
||||
|
||||
justificatifs_query: Query = justificatifs_query.filter(
|
||||
Justificatif.date_debut >= scu.date_debut_anne_scolaire(annee),
|
||||
Justificatif.date_fin <= scu.date_fin_anne_scolaire(annee),
|
||||
Justificatif.date_debut >= scu.date_debut_annee_scolaire(annee),
|
||||
Justificatif.date_fin <= scu.date_fin_annee_scolaire(annee),
|
||||
)
|
||||
|
||||
# cas 8 : group_id filtre les justificatifs d'un groupe d'étudiant
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -8,16 +8,14 @@
|
|||
ScoDoc 9 API : accès aux moduleimpl
|
||||
"""
|
||||
|
||||
from flask import g
|
||||
from flask_json import as_json
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import (
|
||||
FormSemestre,
|
||||
ModuleImpl,
|
||||
)
|
||||
from app.models import ModuleImpl
|
||||
from app.scodoc import sco_liste_notes
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
|
||||
|
@ -62,10 +60,7 @@ def moduleimpl(moduleimpl_id: int):
|
|||
}
|
||||
}
|
||||
"""
|
||||
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
modimpl: ModuleImpl = query.first_or_404()
|
||||
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
|
||||
return modimpl.to_dict(convert_objects=True)
|
||||
|
||||
|
||||
|
@ -87,8 +82,36 @@ def moduleimpl_inscriptions(moduleimpl_id: int):
|
|||
...
|
||||
]
|
||||
"""
|
||||
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
modimpl: ModuleImpl = query.first_or_404()
|
||||
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
|
||||
return [i.to_dict() for i in modimpl.inscriptions]
|
||||
|
||||
|
||||
@bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
|
||||
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def moduleimpl_notes(moduleimpl_id: int):
|
||||
"""Liste des notes dans ce moduleimpl
|
||||
Exemple de résultat :
|
||||
[
|
||||
{
|
||||
"etudid": 17776, // code de l'étudiant
|
||||
"nom": "DUPONT",
|
||||
"prenom": "Luz",
|
||||
"38411": 16.0, // Note dans l'évaluation d'id 38411
|
||||
"38410": 15.0,
|
||||
"moymod": 15.5, // Moyenne INDICATIVE module
|
||||
"moy_ue_2875": 15.5, // Moyenne vers l'UE 2875
|
||||
"moy_ue_2876": 15.5, // Moyenne vers l'UE 2876
|
||||
"moy_ue_2877": 15.5 // Moyenne vers l'UE 2877
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
|
||||
app.set_sco_dept(modimpl.formsemestre.departement.acronym)
|
||||
table, _ = sco_liste_notes.do_evaluation_listenotes(
|
||||
moduleimpl_id=modimpl.id, fmt="json"
|
||||
)
|
||||
return table
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -311,6 +311,13 @@ def group_create(partition_id: int): # partition-group-create
|
|||
args["group_name"] = args["group_name"].strip()
|
||||
if not GroupDescr.check_name(partition, args["group_name"]):
|
||||
return json_error(API_CLIENT_ERROR, "invalid group_name")
|
||||
|
||||
# le numero est optionnel
|
||||
numero = args.get("numero")
|
||||
if numero is None:
|
||||
numeros = [gr.numero or 0 for gr in partition.groups]
|
||||
numero = (max(numeros) + 1) if numeros else 0
|
||||
args["numero"] = numero
|
||||
args["partition_id"] = partition_id
|
||||
try:
|
||||
group = GroupDescr(**args)
|
||||
|
@ -394,6 +401,32 @@ def group_edit(group_id: int):
|
|||
return group.to_dict(with_partition=True)
|
||||
|
||||
|
||||
@bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
|
||||
@api_web_bp.route("/group/<int:group_id>/set_edt_id/<string:edt_id>", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
@as_json
|
||||
def group_set_edt_id(group_id: int, edt_id: str):
|
||||
"""Set edt_id for this group.
|
||||
Contrairement à /edit, peut-être changé pour toute partition
|
||||
ou formsemestre non verrouillé.
|
||||
"""
|
||||
query = GroupDescr.query.filter_by(id=group_id)
|
||||
if g.scodoc_dept:
|
||||
query = (
|
||||
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
group: GroupDescr = query.first_or_404()
|
||||
if not group.partition.formsemestre.can_change_groups():
|
||||
return json_error(401, "opération non autorisée")
|
||||
log(f"group_set_edt_id( {group_id}, '{edt_id}' )")
|
||||
group.edt_id = edt_id
|
||||
db.session.add(group)
|
||||
db.session.commit()
|
||||
return group.to_dict(with_partition=True)
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"])
|
||||
@api_web_bp.route(
|
||||
"/formsemestre/<int:formsemestre_id>/partition/create", methods=["POST"]
|
||||
|
@ -494,6 +527,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
|
|||
db.session.commit()
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id)
|
||||
log(f"formsemestre_order_partitions({partition_ids})")
|
||||
return [
|
||||
partition.to_dict()
|
||||
for partition in formsemestre.partitions.order_by(Partition.numero)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : outils
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
ScoDoc 9 API : accès aux utilisateurs
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
|
@ -15,13 +14,14 @@ from flask_login import current_user, login_required
|
|||
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.auth.models import User, Role, UserRole
|
||||
from app.auth.models import is_valid_password
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import Departement
|
||||
from app.models import Departement, ScoDocSiteConfig
|
||||
from app.scodoc import sco_edt_cal
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -441,3 +441,63 @@ def role_delete(role_name: str):
|
|||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
return {"OK": True}
|
||||
|
||||
|
||||
# @bp.route("/user/<int:uid>/edt")
|
||||
# @api_web_bp.route("/user/<int:uid>/edt")
|
||||
# @login_required
|
||||
# @scodoc
|
||||
# @permission_required(Permission.ScoView)
|
||||
# @as_json
|
||||
# def user_edt(uid: int):
|
||||
# """L'emploi du temps de l'utilisateur.
|
||||
# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
|
||||
|
||||
# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
|
||||
|
||||
# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé)
|
||||
# """
|
||||
# if g.scodoc_dept is None: # route API non départementale
|
||||
# if not current_user.has_permission(Permission.UsersView):
|
||||
# return scu.json_error(403, "accès non autorisé")
|
||||
# user: User = db.session.get(User, uid)
|
||||
# if user is None:
|
||||
# return json_error(404, "user not found")
|
||||
# # Check permission
|
||||
# if current_user.id != user.id:
|
||||
# if g.scodoc_dept:
|
||||
# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView)
|
||||
# if (None not in allowed_depts) and (user.dept not in allowed_depts):
|
||||
# return json_error(404, "user not found")
|
||||
|
||||
# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
|
||||
|
||||
# # Cherche ics
|
||||
# if not user.edt_id:
|
||||
# return json_error(404, "user not configured")
|
||||
# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id)
|
||||
# if not ics_filename:
|
||||
# return json_error(404, "no calendar for this user")
|
||||
|
||||
# _, calendar = sco_edt_cal.load_calendar(ics_filename)
|
||||
|
||||
# # TODO:
|
||||
# # - Construire mapping edt2modimpl: edt_id -> modimpl
|
||||
# # pour cela, considérer tous les formsemestres de la période de l'edt
|
||||
# # (soit on considère l'année scolaire du 1er event, ou celle courante,
|
||||
# # soit on cherche min, max des dates des events)
|
||||
# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping
|
||||
# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event)
|
||||
|
||||
# raise NotImplementedError() # TODO XXX WIP
|
||||
|
||||
# events_scodoc, _ = sco_edt_cal.convert_ics(
|
||||
# calendar,
|
||||
# edt2group=edt2group,
|
||||
# default_group=default_group,
|
||||
# edt2modimpl=edt2modimpl,
|
||||
# )
|
||||
# edt_dict = sco_edt_cal.translate_calendar(
|
||||
# events_scodoc, group_ids, show_modules_titles=show_modules_titles
|
||||
# )
|
||||
# return edt_dict
|
||||
|
|
|
@ -102,6 +102,8 @@ class User(UserMixin, ScoDocModel):
|
|||
token = db.Column(db.Text(), index=True, unique=True)
|
||||
token_expiration = db.Column(db.DateTime)
|
||||
|
||||
# Define the back reference from User to ModuleImpl
|
||||
modimpls = db.relationship("ModuleImpl", back_populates="responsable")
|
||||
roles = db.relationship("Role", secondary="user_role", viewonly=True)
|
||||
Permission = Permission
|
||||
|
||||
|
@ -245,24 +247,26 @@ class User(UserMixin, ScoDocModel):
|
|||
def to_dict(self, include_email=True):
|
||||
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
|
||||
data = {
|
||||
"date_expiration": self.date_expiration.isoformat() + "Z"
|
||||
if self.date_expiration
|
||||
else None,
|
||||
"date_modif_passwd": self.date_modif_passwd.isoformat() + "Z"
|
||||
if self.date_modif_passwd
|
||||
else None,
|
||||
"date_created": self.date_created.isoformat() + "Z"
|
||||
if self.date_created
|
||||
else None,
|
||||
"date_expiration": (
|
||||
self.date_expiration.isoformat() + "Z" if self.date_expiration else None
|
||||
),
|
||||
"date_modif_passwd": (
|
||||
self.date_modif_passwd.isoformat() + "Z"
|
||||
if self.date_modif_passwd
|
||||
else None
|
||||
),
|
||||
"date_created": (
|
||||
self.date_created.isoformat() + "Z" if self.date_created else None
|
||||
),
|
||||
"dept": self.dept,
|
||||
"id": self.id,
|
||||
"active": self.active,
|
||||
"cas_id": self.cas_id,
|
||||
"cas_allow_login": self.cas_allow_login,
|
||||
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
|
||||
"cas_last_login": self.cas_last_login.isoformat() + "Z"
|
||||
if self.cas_last_login
|
||||
else None,
|
||||
"cas_last_login": (
|
||||
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None
|
||||
),
|
||||
"edt_id": self.edt_id,
|
||||
"status_txt": "actif" if self.active else "fermé",
|
||||
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
|
||||
|
@ -477,8 +481,8 @@ class User(UserMixin, ScoDocModel):
|
|||
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})"
|
||||
|
||||
@staticmethod
|
||||
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
|
||||
"""Returns id from the string "Dupont Pierre (dupont)"
|
||||
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]:
|
||||
"""Returns User instance from the string "Dupont Pierre (dupont)"
|
||||
or None if user does not exist
|
||||
"""
|
||||
match = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
|
@ -486,7 +490,7 @@ class User(UserMixin, ScoDocModel):
|
|||
user_name = match.group(1)
|
||||
u = User.query.filter_by(user_name=user_name).first()
|
||||
if u:
|
||||
return u.id
|
||||
return u
|
||||
return None
|
||||
|
||||
def get_nom_fmt(self):
|
||||
|
@ -599,8 +603,19 @@ class Role(db.Model):
|
|||
"""Create default roles if missing, then, if reset_permissions,
|
||||
reset their permissions to default values.
|
||||
"""
|
||||
Role.reset_roles_permissions(
|
||||
SCO_ROLES_DEFAULTS, reset_permissions=reset_permissions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def reset_roles_permissions(roles_perms: dict[str, tuple], reset_permissions=True):
|
||||
"""Ajoute les permissions aux roles
|
||||
roles_perms : { "role_name" : (permission, ...) }
|
||||
reset_permissions : si vrai efface permissions déja existantes
|
||||
Si le role n'existe pas, il est (re) créé.
|
||||
"""
|
||||
default_role = "Observateur"
|
||||
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
|
||||
for role_name, permissions in roles_perms.items():
|
||||
role = Role.query.filter_by(name=role_name).first()
|
||||
if role is None:
|
||||
role = Role(name=role_name)
|
||||
|
|
|
@ -54,6 +54,7 @@ def _login_form():
|
|||
title=_("Sign In"),
|
||||
form=form,
|
||||
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
|
||||
is_cas_forced=ScoDocSiteConfig.is_cas_forced(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -21,9 +21,9 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
|
|||
return ""
|
||||
ref_comp = ue.formation.referentiel_competence
|
||||
if ref_comp is None:
|
||||
return f"""<div class="ue_advanced">
|
||||
return f"""<div class="scobox ue_advanced">
|
||||
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
|
||||
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
|
||||
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
|
||||
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
|
||||
}">associer un référentiel de compétence</a>
|
||||
</div>
|
||||
|
@ -31,24 +31,33 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
|
|||
|
||||
H = [
|
||||
"""
|
||||
<div class="ue_advanced">
|
||||
<h3>Parcours du BUT</h3>
|
||||
<div class="scobox ue_advanced">
|
||||
<div class="scobox-title">Parcours du BUT</div>
|
||||
"""
|
||||
]
|
||||
# Choix des parcours
|
||||
ue_pids = [p.id for p in ue.parcours]
|
||||
H.append("""<form id="choix_parcours">""")
|
||||
H.append(
|
||||
"""
|
||||
<div class="help">
|
||||
Cocher tous les parcours dans lesquels cette UE est utilisée,
|
||||
même si vous n'offrez pas ce parcours dans votre département.
|
||||
Sans quoi, les UEs de Tronc Commun ne seront pas reconnues.
|
||||
Ne cocher aucun parcours est équivalent à tous les cocher.
|
||||
</div>
|
||||
<form id="choix_parcours" style="margin-top: 12px;">
|
||||
"""
|
||||
)
|
||||
|
||||
ects_differents = {
|
||||
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
|
||||
} != {None}
|
||||
for parcour in ref_comp.parcours:
|
||||
ects_parcour = ue.get_ects(parcour)
|
||||
ects_parcour_txt = (
|
||||
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
|
||||
)
|
||||
H.append(
|
||||
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
|
||||
f"""<label><input type="checkbox" name="{parcour.id}" value="{parcour.id}"
|
||||
{'checked' if parcour.id in ue_pids else ""}
|
||||
onclick="set_ue_parcour(this);"
|
||||
data-setter="{url_for("apiweb.set_ue_parcours",
|
||||
|
@ -62,7 +71,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
|
|||
<ul>
|
||||
<li>
|
||||
<a class="stdlink" href="{
|
||||
url_for("notes.ue_parcours_ects",
|
||||
url_for("notes.ue_parcours_ects",
|
||||
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
|
||||
}">définir des ECTS différents dans chaque parcours</a>
|
||||
</li>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -9,12 +9,14 @@
|
|||
|
||||
import collections
|
||||
import datetime
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from flask import g, has_request_context, url_for
|
||||
|
||||
from app import db
|
||||
from app.comp.moy_mod import ModuleImplResults
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import Evaluation, FormSemestre, Identite
|
||||
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
|
||||
from app.models.groups import GroupDescr
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_bulletins, sco_utils as scu
|
||||
|
@ -104,9 +106,11 @@ class BulletinBUT:
|
|||
"competence": None, # XXX TODO lien avec référentiel
|
||||
"moyenne": None,
|
||||
# Le bonus sport appliqué sur cette UE
|
||||
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
|
||||
if res.bonus_ues is not None and ue.id in res.bonus_ues
|
||||
else fmt_note(0.0),
|
||||
"bonus": (
|
||||
fmt_note(res.bonus_ues[ue.id][etud.id])
|
||||
if res.bonus_ues is not None and ue.id in res.bonus_ues
|
||||
else fmt_note(0.0)
|
||||
),
|
||||
"malus": fmt_note(res.malus[ue.id][etud.id]),
|
||||
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
|
||||
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
|
||||
|
@ -181,14 +185,16 @@ class BulletinBUT:
|
|||
"is_external": ue_capitalisee.is_external,
|
||||
"date_capitalisation": ue_capitalisee.event_date,
|
||||
"formsemestre_id": ue_capitalisee.formsemestre_id,
|
||||
"bul_orig_url": url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
formsemestre_id=ue_capitalisee.formsemestre_id,
|
||||
)
|
||||
if ue_capitalisee.formsemestre_id
|
||||
else None,
|
||||
"bul_orig_url": (
|
||||
url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
formsemestre_id=ue_capitalisee.formsemestre_id,
|
||||
)
|
||||
if ue_capitalisee.formsemestre_id
|
||||
else None
|
||||
),
|
||||
"ressources": {}, # sans détail en BUT
|
||||
"saes": {},
|
||||
}
|
||||
|
@ -225,15 +231,17 @@ class BulletinBUT:
|
|||
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
|
||||
d[modimpl.module.code] = {
|
||||
"id": modimpl.id,
|
||||
"titre": modimpl.module.titre,
|
||||
"titre": modimpl.module.titre_str(),
|
||||
"code_apogee": modimpl.module.code_apogee,
|
||||
"url": url_for(
|
||||
"notes.moduleimpl_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
moduleimpl_id=modimpl.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na",
|
||||
"url": (
|
||||
url_for(
|
||||
"notes.moduleimpl_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
moduleimpl_id=modimpl.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na"
|
||||
),
|
||||
"moyenne": {
|
||||
# # moyenne indicative de module: moyenne des UE,
|
||||
# # ignorant celles sans notes (nan)
|
||||
|
@ -242,68 +250,115 @@ class BulletinBUT:
|
|||
# "max": fmt_note(moyennes_etuds.max()),
|
||||
# "moy": fmt_note(moyennes_etuds.mean()),
|
||||
},
|
||||
"evaluations": [
|
||||
self.etud_eval_results(etud, e)
|
||||
for e in modimpl.evaluations
|
||||
if (e.visibulletin or version == "long")
|
||||
and (e.id in modimpl_results.evaluations_etat)
|
||||
and (
|
||||
modimpl_results.evaluations_etat[e.id].is_complete
|
||||
or self.prefs["bul_show_all_evals"]
|
||||
"evaluations": (
|
||||
self.etud_list_modimpl_evaluations(
|
||||
etud, modimpl, modimpl_results, version
|
||||
)
|
||||
]
|
||||
if version != "short"
|
||||
else [],
|
||||
if version != "short"
|
||||
else []
|
||||
),
|
||||
}
|
||||
return d
|
||||
|
||||
def etud_eval_results(self, etud, e: Evaluation) -> dict:
|
||||
def etud_list_modimpl_evaluations(
|
||||
self,
|
||||
etud: Identite,
|
||||
modimpl: ModuleImpl,
|
||||
modimpl_results: ModuleImplResults,
|
||||
version: str,
|
||||
) -> list[dict]:
|
||||
"""Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
|
||||
evaluation: Evaluation
|
||||
eval_results = []
|
||||
for evaluation in modimpl.evaluations:
|
||||
if (
|
||||
(evaluation.visibulletin or version == "long")
|
||||
and (evaluation.id in modimpl_results.evaluations_etat)
|
||||
and (
|
||||
modimpl_results.evaluations_etat[evaluation.id].is_complete
|
||||
or self.prefs["bul_show_all_evals"]
|
||||
)
|
||||
):
|
||||
eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
|
||||
evaluation.id
|
||||
]
|
||||
|
||||
if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
|
||||
not np.isnan(eval_notes[etud.id])
|
||||
):
|
||||
eval_results.append(
|
||||
self.etud_eval_results(etud, evaluation, eval_notes)
|
||||
)
|
||||
return eval_results
|
||||
|
||||
def etud_eval_results(
|
||||
self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
|
||||
) -> dict:
|
||||
"dict resultats d'un étudiant à une évaluation"
|
||||
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
|
||||
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
|
||||
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
|
||||
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
|
||||
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
|
||||
try:
|
||||
etud_ues_ids = self.res.etud_ues_ids(etud.id)
|
||||
poids = {
|
||||
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
|
||||
ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
|
||||
for ue in self.res.ues
|
||||
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
|
||||
}
|
||||
except KeyError:
|
||||
poids = collections.defaultdict(lambda: 0.0)
|
||||
d = {
|
||||
"id": e.id,
|
||||
"coef": fmt_note(e.coefficient)
|
||||
if e.evaluation_type == scu.EVALUATION_NORMALE
|
||||
else None,
|
||||
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
|
||||
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
|
||||
"description": e.description,
|
||||
"evaluation_type": e.evaluation_type,
|
||||
"note": {
|
||||
"value": fmt_note(
|
||||
eval_notes[etud.id],
|
||||
note_max=e.note_max,
|
||||
),
|
||||
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
|
||||
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
|
||||
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
|
||||
},
|
||||
"id": evaluation.id,
|
||||
"coef": (
|
||||
fmt_note(evaluation.coefficient)
|
||||
if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
|
||||
else None
|
||||
),
|
||||
"date_debut": (
|
||||
evaluation.date_debut.isoformat() if evaluation.date_debut else None
|
||||
),
|
||||
"date_fin": (
|
||||
evaluation.date_fin.isoformat() if evaluation.date_fin else None
|
||||
),
|
||||
"description": evaluation.description,
|
||||
"evaluation_type": evaluation.evaluation_type,
|
||||
"note": (
|
||||
{
|
||||
"value": fmt_note(
|
||||
eval_notes[etud.id],
|
||||
note_max=evaluation.note_max,
|
||||
),
|
||||
"min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
|
||||
"max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
|
||||
"moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
|
||||
}
|
||||
if not evaluation.is_blocked()
|
||||
else {}
|
||||
),
|
||||
"poids": poids,
|
||||
"url": url_for(
|
||||
"notes.evaluation_listenotes",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
evaluation_id=e.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na",
|
||||
"url": (
|
||||
url_for(
|
||||
"notes.evaluation_listenotes",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
evaluation_id=evaluation.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na"
|
||||
),
|
||||
# deprecated (supprimer avant #sco9.7)
|
||||
"date": e.date_debut.isoformat() if e.date_debut else None,
|
||||
"heure_debut": e.date_debut.time().isoformat("minutes")
|
||||
if e.date_debut
|
||||
else None,
|
||||
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
|
||||
"date": (
|
||||
evaluation.date_debut.isoformat() if evaluation.date_debut else None
|
||||
),
|
||||
"heure_debut": (
|
||||
evaluation.date_debut.time().isoformat("minutes")
|
||||
if evaluation.date_debut
|
||||
else None
|
||||
),
|
||||
"heure_fin": (
|
||||
evaluation.date_fin.time().isoformat("minutes")
|
||||
if evaluation.date_fin
|
||||
else None
|
||||
),
|
||||
}
|
||||
return d
|
||||
|
||||
|
@ -343,25 +398,18 @@ class BulletinBUT:
|
|||
"short" : ne descend pas plus bas que les modules.
|
||||
|
||||
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
|
||||
(bulletins non publiés).
|
||||
(bulletins non publiés sur la passerelle).
|
||||
"""
|
||||
if version not in scu.BULLETINS_VERSIONS_BUT:
|
||||
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
|
||||
res = self.res
|
||||
formsemestre = res.formsemestre
|
||||
etat_inscription = etud.inscription_etat(formsemestre.id)
|
||||
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
|
||||
published = (not formsemestre.bul_hide_xml) or force_publishing
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
|
||||
else:
|
||||
etud_ues_ids = res.etud_ues_ids(etud.id)
|
||||
|
||||
d = {
|
||||
"version": "0",
|
||||
"type": "BUT",
|
||||
"date": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
"publie": not formsemestre.bul_hide_xml,
|
||||
"etat_inscription": etud.inscription_etat(formsemestre.id),
|
||||
"etudiant": etud.to_dict_bul(),
|
||||
"formation": {
|
||||
"id": formsemestre.formation.id,
|
||||
|
@ -370,15 +418,21 @@ class BulletinBUT:
|
|||
"titre": formsemestre.formation.titre,
|
||||
},
|
||||
"formsemestre_id": formsemestre.id,
|
||||
"etat_inscription": etat_inscription,
|
||||
"options": sco_preferences.bulletin_option_affichage(
|
||||
formsemestre, self.prefs
|
||||
),
|
||||
}
|
||||
if not published:
|
||||
published = (not formsemestre.bul_hide_xml) or force_publishing
|
||||
if not published or d["etat_inscription"] is False:
|
||||
return d
|
||||
|
||||
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
|
||||
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
|
||||
else:
|
||||
etud_ues_ids = res.etud_ues_ids(etud.id)
|
||||
|
||||
nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
|
||||
etud_groups = sco_groups.get_etud_formsemestre_groups(
|
||||
etud, formsemestre, only_to_show=True
|
||||
)
|
||||
|
@ -393,7 +447,7 @@ class BulletinBUT:
|
|||
}
|
||||
if self.prefs["bul_show_abs"]:
|
||||
semestre_infos["absences"] = {
|
||||
"injustifie": nbabs - nbabsjust,
|
||||
"injustifie": nbabsnj,
|
||||
"total": nbabs,
|
||||
"metrique": {
|
||||
"H.": "Heure(s)",
|
||||
|
@ -410,7 +464,7 @@ class BulletinBUT:
|
|||
semestre_infos.update(
|
||||
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
|
||||
)
|
||||
if etat_inscription == scu.INSCRIT:
|
||||
if d["etat_inscription"] == scu.INSCRIT:
|
||||
# moyenne des moyennes générales du semestre
|
||||
semestre_infos["notes"] = {
|
||||
"value": fmt_note(res.etud_moy_gen[etud.id]),
|
||||
|
@ -499,10 +553,8 @@ class BulletinBUT:
|
|||
d["etud"]["etat_civil"] = etud.etat_civil
|
||||
d.update(self.res.sem)
|
||||
etud_etat = self.res.get_etud_etat(etud.id)
|
||||
d["filigranne"] = sco_bulletins_pdf.get_filigranne(
|
||||
etud_etat,
|
||||
self.prefs,
|
||||
decision_sem=d["semestre"].get("decision"),
|
||||
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
|
||||
etud_etat, self.prefs, etud.id, res=self.res
|
||||
)
|
||||
if etud_etat == scu.DEMISSION:
|
||||
d["demission"] = "(Démission)"
|
||||
|
@ -512,7 +564,7 @@ class BulletinBUT:
|
|||
d["demission"] = ""
|
||||
|
||||
# --- Absences
|
||||
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
|
||||
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
|
||||
|
||||
# --- Decision Jury
|
||||
infos, _ = sco_bulletins.etud_descr_situation_semestre(
|
||||
|
@ -527,9 +579,9 @@ class BulletinBUT:
|
|||
|
||||
d.update(infos)
|
||||
# --- Rangs
|
||||
d[
|
||||
"rang_nt"
|
||||
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
|
||||
d["rang_nt"] = (
|
||||
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
|
||||
)
|
||||
d["rang_txt"] = "Rang " + d["rang_nt"]
|
||||
|
||||
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -104,8 +104,10 @@ def _build_bulletin_but_infos(
|
|||
bulletins_sem = BulletinBUT(formsemestre)
|
||||
if fmt == "pdf":
|
||||
bul: dict = bulletins_sem.bulletin_etud_complet(etud)
|
||||
filigranne = bul["filigranne"]
|
||||
else: # la même chose avec un peu moins d'infos
|
||||
bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True)
|
||||
filigranne = ""
|
||||
decision_ues = (
|
||||
{x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
|
||||
if "semestre" in bul and "decision_ue" in bul["semestre"]
|
||||
|
@ -117,6 +119,12 @@ def _build_bulletin_but_infos(
|
|||
refcomp = formsemestre.formation.referentiel_competence
|
||||
if refcomp is None:
|
||||
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
|
||||
|
||||
warn_html = cursus_but.formsemestre_warning_apc_setup(
|
||||
formsemestre, bulletins_sem.res
|
||||
)
|
||||
if warn_html:
|
||||
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html)
|
||||
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
|
||||
refcomp, etud
|
||||
)
|
||||
|
@ -131,6 +139,7 @@ def _build_bulletin_but_infos(
|
|||
"decision_ues": decision_ues,
|
||||
"ects_total": ects_total,
|
||||
"etud": etud,
|
||||
"filigranne": filigranne,
|
||||
"formsemestre": formsemestre,
|
||||
"logo": logo,
|
||||
"prefs": bulletins_sem.prefs,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -31,6 +31,7 @@ from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
|||
from app.scodoc.sco_logos import Logo
|
||||
from app.scodoc.sco_pdf import PDFLOCK, SU
|
||||
from app.scodoc.sco_preferences import SemPreferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
def make_bulletin_but_court_pdf(
|
||||
|
@ -48,6 +49,7 @@ def make_bulletin_but_court_pdf(
|
|||
ects_total: float = 0.0,
|
||||
etud: Identite = None,
|
||||
formsemestre: FormSemestre = None,
|
||||
filigranne=""
|
||||
logo: Logo = None,
|
||||
prefs: SemPreferences = None,
|
||||
title: str = "",
|
||||
|
@ -86,6 +88,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
decision_ues: dict = None,
|
||||
ects_total: float = 0.0,
|
||||
etud: Identite = None,
|
||||
filigranne="",
|
||||
formsemestre: FormSemestre = None,
|
||||
logo: Logo = None,
|
||||
prefs: SemPreferences = None,
|
||||
|
@ -95,7 +98,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
] = None,
|
||||
ues_acronyms: list[str] = None,
|
||||
):
|
||||
super().__init__(bul, authuser=current_user)
|
||||
super().__init__(bul, authuser=current_user, filigranne=filigranne)
|
||||
self.bul = bul
|
||||
self.cursus = cursus
|
||||
self.decision_ues = decision_ues
|
||||
|
@ -192,7 +195,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
"""Génère la partie "titre" du bulletin de notes.
|
||||
Renvoie une liste d'objets platypus
|
||||
"""
|
||||
# comme les bulletins standard, mais avec notre préférence
|
||||
# comme les bulletins standards, mais avec notre préférence
|
||||
return super().bul_title_pdf(preference_field=preference_field)
|
||||
|
||||
def bul_part_below(self, fmt="pdf") -> list:
|
||||
|
@ -341,9 +344,11 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
for mod in self.bul[mod_type]:
|
||||
row = [mod, bul[mod_type][mod]["titre"]]
|
||||
row += [
|
||||
bul["ues"][ue][mod_type][mod]["moyenne"]
|
||||
if mod in bul["ues"][ue][mod_type]
|
||||
else ""
|
||||
(
|
||||
bul["ues"][ue][mod_type][mod]["moyenne"]
|
||||
if mod in bul["ues"][ue][mod_type]
|
||||
else ""
|
||||
)
|
||||
for ue in self.ues_acronyms
|
||||
]
|
||||
rows.append(row)
|
||||
|
@ -404,6 +409,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
|
||||
def boite_identite(self) -> list:
|
||||
"Les informations sur l'identité et l'inscription de l'étudiant"
|
||||
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
|
||||
|
||||
return [
|
||||
Paragraph(
|
||||
SU(f"""{self.etud.nomprenom}"""),
|
||||
|
@ -414,7 +421,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
f"""
|
||||
<b>{self.bul["demission"]}</b><br/>
|
||||
Formation: {self.formsemestre.titre_num()}<br/>
|
||||
Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
|
||||
{'Parcours ' + parcour.code + '<br/>' if parcour else ''}
|
||||
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
|
||||
"""
|
||||
),
|
||||
style=self.style_base,
|
||||
|
@ -518,7 +526,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
if self.bul["semestre"].get("decision_annee", None):
|
||||
txt += f"""
|
||||
Décision saisie le {
|
||||
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y")
|
||||
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
|
||||
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
|
||||
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
|
||||
<br/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
|||
La génération du bulletin PDF suit le chemin suivant:
|
||||
|
||||
- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
|
||||
|
||||
|
||||
bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
|
||||
|
||||
- sco_bulletins_generator.make_formsemestre_bulletin_etud()
|
||||
|
@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
|
|||
from reportlab.lib.units import cm, mm
|
||||
from reportlab.platypus import Paragraph, Spacer
|
||||
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.models import Evaluation, ScoDocSiteConfig
|
||||
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
||||
from app.scodoc import gen_tables
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
|
@ -269,7 +269,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
date_capitalisation = ue.get("date_capitalisation")
|
||||
if date_capitalisation:
|
||||
fields_bmr.append(
|
||||
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
|
||||
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
|
||||
)
|
||||
t = {
|
||||
"titre": " - ".join(fields_bmr),
|
||||
|
@ -422,7 +422,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
|
||||
"lignes des évaluations"
|
||||
for e in evaluations:
|
||||
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
|
||||
coef = (
|
||||
e["coef"]
|
||||
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
|
||||
else "*"
|
||||
)
|
||||
t = {
|
||||
"titre": f"{e['description'] or ''}",
|
||||
"moyenne": e["note"]["value"],
|
||||
|
@ -431,7 +435,10 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
),
|
||||
"coef": coef,
|
||||
"_coef_pdf": Paragraph(
|
||||
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
|
||||
f"""<para align=right fontSize={self.small_fontsize}><i>{
|
||||
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
|
||||
else "bonus"
|
||||
}</i></para>"""
|
||||
),
|
||||
"_pdf_style": [
|
||||
(
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -38,14 +38,11 @@ import datetime
|
|||
from xml.etree import ElementTree
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app import log
|
||||
from app import db, log
|
||||
from app.but import bulletin_but
|
||||
from app.models import BulAppreciations, FormSemestre, Identite
|
||||
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
|
||||
import app.scodoc.sco_utils as scu
|
||||
import app.scodoc.notesdb as ndb
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_edit_ue
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_photos
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_xml
|
||||
|
@ -202,12 +199,12 @@ def bulletin_but_xml_compat(
|
|||
if e.visibulletin or version == "long":
|
||||
x_eval = Element(
|
||||
"evaluation",
|
||||
date_debut=e.date_debut.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
date_fin=e.date_fin.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
date_debut=(
|
||||
e.date_debut.isoformat() if e.date_debut else ""
|
||||
),
|
||||
date_fin=(
|
||||
e.date_fin.isoformat() if e.date_debut else ""
|
||||
),
|
||||
coefficient=str(e.coefficient),
|
||||
# pas les poids en XML compat
|
||||
evaluation_type=str(e.evaluation_type),
|
||||
|
@ -215,9 +212,9 @@ def bulletin_but_xml_compat(
|
|||
# notes envoyées sur 20, ceci juste pour garder trace:
|
||||
note_max_origin=str(e.note_max),
|
||||
# --- deprecated
|
||||
jour=e.date_debut.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
jour=(
|
||||
e.date_debut.isoformat() if e.date_debut else ""
|
||||
),
|
||||
heure_debut=e.heure_debut(),
|
||||
heure_fin=e.heure_fin(),
|
||||
)
|
||||
|
@ -244,7 +241,7 @@ def bulletin_but_xml_compat(
|
|||
|
||||
# --- Absences
|
||||
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
|
||||
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
|
||||
_, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
|
||||
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
|
||||
|
||||
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
|
||||
|
@ -294,17 +291,18 @@ def bulletin_but_xml_compat(
|
|||
"decisions_ue"
|
||||
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
|
||||
for ue_id in decision["decisions_ue"].keys():
|
||||
ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
|
||||
doc.append(
|
||||
Element(
|
||||
"decision_ue",
|
||||
ue_id=str(ue["ue_id"]),
|
||||
numero=quote_xml_attr(ue["numero"]),
|
||||
acronyme=quote_xml_attr(ue["acronyme"]),
|
||||
titre=quote_xml_attr(ue["titre"]),
|
||||
code=decision["decisions_ue"][ue_id]["code"],
|
||||
ue = db.session.get(UniteEns, ue_id)
|
||||
if ue:
|
||||
doc.append(
|
||||
Element(
|
||||
"decision_ue",
|
||||
ue_id=str(ue.id),
|
||||
numero=quote_xml_attr(ue.numero),
|
||||
acronyme=quote_xml_attr(ue.acronyme),
|
||||
titre=quote_xml_attr(ue.titre or ""),
|
||||
code=decision["decisions_ue"][ue_id]["code"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for aut in decision["autorisations"]:
|
||||
doc.append(
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Code expérimental: si deux référentiel sont presques identiques
|
||||
(mêmes compétences, niveaux, parcours)
|
||||
essaie de changer une formation de référentiel.
|
||||
"""
|
||||
|
||||
from app import clear_scodoc_cache, db
|
||||
|
||||
from app.models import (
|
||||
ApcParcours,
|
||||
ApcReferentielCompetences,
|
||||
ApcValidationRCUE,
|
||||
Formation,
|
||||
FormSemestreInscription,
|
||||
UniteEns,
|
||||
)
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
def formation_change_referentiel(
|
||||
formation: Formation, new_ref: ApcReferentielCompetences
|
||||
):
|
||||
"""Try to change ref."""
|
||||
if not formation.referentiel_competence:
|
||||
raise ScoValueError("formation non associée à un référentiel")
|
||||
if not isinstance(new_ref, ApcReferentielCompetences):
|
||||
raise ScoValueError("nouveau référentiel invalide")
|
||||
|
||||
r = formation.referentiel_competence.map_to_other_referentiel(new_ref)
|
||||
if isinstance(r, str):
|
||||
raise ScoValueError(f"référentiels incompatibles: {r}")
|
||||
parcours_map, competences_map, niveaux_map = r
|
||||
|
||||
formation.referentiel_competence = new_ref
|
||||
db.session.add(formation)
|
||||
# UEs - Niveaux et UEs - parcours
|
||||
for ue in formation.ues:
|
||||
if ue.niveau_competence:
|
||||
ue.niveau_competence_id = niveaux_map[ue.niveau_competence_id]
|
||||
db.session.add(ue)
|
||||
if ue.parcours:
|
||||
new_list = [ApcParcours.query.get(parcours_map[p.id]) for p in ue.parcours]
|
||||
ue.parcours.clear()
|
||||
ue.parcours.extend(new_list)
|
||||
db.session.add(ue)
|
||||
# Modules / parcours et app_critiques
|
||||
for module in formation.modules:
|
||||
if module.parcours:
|
||||
new_list = [
|
||||
ApcParcours.query.get(parcours_map[p.id]) for p in module.parcours
|
||||
]
|
||||
module.parcours.clear()
|
||||
module.parcours.extend(new_list)
|
||||
db.session.add(module)
|
||||
if module.app_critiques: # efface les apprentissages critiques
|
||||
module.app_critiques.clear()
|
||||
db.session.add(module)
|
||||
# ApcValidationRCUE
|
||||
for valid_rcue in ApcValidationRCUE.query.join(
|
||||
UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id
|
||||
).filter_by(formation_id=formation.id):
|
||||
if valid_rcue.parcour:
|
||||
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
|
||||
db.session.add(valid_rcue)
|
||||
for valid_rcue in ApcValidationRCUE.query.join(
|
||||
UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id
|
||||
).filter_by(formation_id=formation.id):
|
||||
if valid_rcue.parcour:
|
||||
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
|
||||
db.session.add(valid_rcue)
|
||||
# FormSemestre / parcours_formsemestre
|
||||
for formsemestre in formation.formsemestres:
|
||||
new_list = [
|
||||
ApcParcours.query.get(parcours_map[p.id]) for p in formsemestre.parcours
|
||||
]
|
||||
formsemestre.parcours.clear()
|
||||
formsemestre.parcours.extend(new_list)
|
||||
db.session.add(formsemestre)
|
||||
# FormSemestreInscription.parcour_id
|
||||
for inscr in FormSemestreInscription.query.filter_by(
|
||||
formsemestre_id=formsemestre.id
|
||||
).filter(FormSemestreInscription.parcour_id != None):
|
||||
if inscr.parcour_id is not None:
|
||||
inscr.parcour_id = parcours_map[inscr.parcour_id]
|
||||
#
|
||||
db.session.commit()
|
||||
clear_scodoc_cache()
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -23,29 +23,21 @@ from app.comp.res_but import ResultatsSemestreBUT
|
|||
from app.comp.res_compat import NotesTableCompat
|
||||
|
||||
from app.models.but_refcomp import (
|
||||
ApcAnneeParcours,
|
||||
ApcCompetence,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcParcoursNiveauCompetence,
|
||||
ApcReferentielCompetences,
|
||||
)
|
||||
from app.models import Scolog, ScolarAutorisationInscription
|
||||
from app.models.but_validations import (
|
||||
ApcValidationAnnee,
|
||||
ApcValidationRCUE,
|
||||
)
|
||||
from app.models.ues import UEParcours
|
||||
from app.models.but_validations import ApcValidationRCUE
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formations import Formation
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.validations import ScolarFormSemestreValidation
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
|
||||
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
|
||||
from app.scodoc import sco_cursus_dut
|
||||
|
||||
|
||||
|
@ -119,8 +111,15 @@ class EtudCursusBUT:
|
|||
|
||||
self.validation_par_competence_et_annee = {}
|
||||
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
|
||||
validation_rcue: ApcValidationRCUE
|
||||
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
|
||||
niveau = validation_rcue.niveau()
|
||||
if niveau is None:
|
||||
raise ScoValueError(
|
||||
"""UE d'un RCUE non associée à un niveau de compétence.
|
||||
Vérifiez la formation et les associations de ses UEs.
|
||||
"""
|
||||
)
|
||||
if not niveau.competence.id in self.validation_par_competence_et_annee:
|
||||
self.validation_par_competence_et_annee[niveau.competence.id] = {}
|
||||
previous_validation = self.validation_par_competence_et_annee.get(
|
||||
|
@ -436,15 +435,38 @@ def formsemestre_warning_apc_setup(
|
|||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
return ""
|
||||
url_formation = url_for(
|
||||
"notes.ue_table",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formation_id=formsemestre.formation.id,
|
||||
semestre_idx=formsemestre.semestre_id,
|
||||
)
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
return f"""<div class="formsemestre_status_warning">
|
||||
La <a class="stdlink" href="{
|
||||
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
|
||||
}">formation n'est pas associée à un référentiel de compétence.</a>
|
||||
La <a class="stdlink" href="{url_formation}">formation
|
||||
n'est pas associée à un référentiel de compétence.</a>
|
||||
</div>
|
||||
"""
|
||||
# Vérifie les niveaux de chaque parcours
|
||||
H = []
|
||||
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
|
||||
if not formsemestre.parcours:
|
||||
nb_ues_sans_parcours = len(
|
||||
formsemestre.formation.query_ues_parcour(None)
|
||||
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
|
||||
.all()
|
||||
)
|
||||
nb_ues_tot = (
|
||||
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
|
||||
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
|
||||
.count()
|
||||
)
|
||||
if nb_ues_sans_parcours != nb_ues_tot:
|
||||
H.append(
|
||||
"""Le semestre n'est associé à aucun parcours,
|
||||
mais les UEs de la formation ont des parcours
|
||||
"""
|
||||
)
|
||||
# Vérifie les niveaux de chaque parcours
|
||||
for parcour in formsemestre.parcours or [None]:
|
||||
annee = (formsemestre.semestre_id + 1) // 2
|
||||
niveaux_ids = {
|
||||
|
@ -469,7 +491,8 @@ def formsemestre_warning_apc_setup(
|
|||
if not H:
|
||||
return ""
|
||||
return f"""<div class="formsemestre_status_warning">
|
||||
Problème dans la configuration de la formation:
|
||||
Problème dans la
|
||||
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
|
||||
<ul>
|
||||
<li>{ '</li><li>'.join(H) }</li>
|
||||
</ul>
|
||||
|
@ -482,6 +505,79 @@ def formsemestre_warning_apc_setup(
|
|||
"""
|
||||
|
||||
|
||||
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
|
||||
"""Vérifie que tous les niveaux de compétences de cette année de formation
|
||||
ont bien des UEs.
|
||||
Afin de ne pas générer trop de messages, on ne considère que les parcours
|
||||
du référentiel de compétences pour lesquels au moins une UE a été associée.
|
||||
|
||||
Renvoie fragment de html
|
||||
"""
|
||||
annee = (semestre_idx - 1) // 2 + 1 # année BUT
|
||||
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
|
||||
if not ref_comp:
|
||||
return "" # détecté ailleurs...
|
||||
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
|
||||
parcours_ids = {
|
||||
uep.parcours_id
|
||||
for uep in UEParcours.query.join(UniteEns).filter_by(
|
||||
formation_id=formation.id, type=UE_STANDARD
|
||||
)
|
||||
}
|
||||
for parcour in ref_comp.parcours:
|
||||
if parcour.id not in parcours_ids:
|
||||
continue # saute parcours associés à aucune UE (tous semestres)
|
||||
niveaux_sans_ue = []
|
||||
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
|
||||
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
|
||||
for niveau in niveaux:
|
||||
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
|
||||
if not ues:
|
||||
niveaux_sans_ue.append(niveau)
|
||||
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
|
||||
if niveaux_sans_ue:
|
||||
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
|
||||
#
|
||||
H = []
|
||||
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
|
||||
H.append(
|
||||
f"""<li>Parcours {parcour_code} : {
|
||||
len(niveaux)} niveaux sans UEs :
|
||||
<span class="niveau-nom"><span>
|
||||
{ '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}'
|
||||
for niveau in niveaux
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
"""
|
||||
)
|
||||
# Combien de compétences de tronc commun ?
|
||||
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
|
||||
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
|
||||
nb_ues_tc = len(
|
||||
formation.query_ues_parcour(None)
|
||||
.filter(UniteEns.semestre_idx == semestre_idx)
|
||||
.all()
|
||||
)
|
||||
if nb_niveaux_tc != nb_ues_tc:
|
||||
H.append(
|
||||
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
|
||||
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
|
||||
vous avez des UEs différenciées par parcours)</li>"""
|
||||
)
|
||||
|
||||
if H:
|
||||
return f"""<div class="formation_semestre_niveaux_warning">
|
||||
<div>Problèmes détectés à corriger :</div>
|
||||
<ul>
|
||||
{"".join(H)}
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
return "" # no problem detected
|
||||
|
||||
|
||||
def ue_associee_au_niveau_du_parcours(
|
||||
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
|
||||
) -> UniteEns:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -10,9 +10,11 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
from wtforms import SelectField, SubmitField
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
|
||||
class FormationRefCompForm(FlaskForm):
|
||||
"Choix d'un référentiel"
|
||||
referentiel_competence = SelectField(
|
||||
"Choisir parmi les référentiels déjà chargés :"
|
||||
)
|
||||
|
@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm):
|
|||
|
||||
|
||||
class RefCompLoadForm(FlaskForm):
|
||||
"Upload d'un référentiel"
|
||||
referentiel_standard = SelectField(
|
||||
"Choisir un référentiel de compétences officiel BUT"
|
||||
)
|
||||
|
@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm):
|
|||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class FormationChangeRefCompForm(FlaskForm):
|
||||
"choix d'un nouveau ref. comp. pour une formation"
|
||||
object_select = SelectField(
|
||||
"Choisir le nouveau référentiel", validators=[DataRequired()]
|
||||
)
|
||||
submit = SubmitField("Changer le référentiel de la formation")
|
||||
cancel = SubmitField("Annuler")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
from xml.etree import ElementTree
|
||||
|
@ -23,9 +23,12 @@ from app.models.but_refcomp import (
|
|||
from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
|
||||
|
||||
|
||||
def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
||||
def orebut_import_refcomp(
|
||||
xml_data: str, dept_id: int, orig_filename=None
|
||||
) -> ApcReferentielCompetences:
|
||||
"""Importation XML Orébut
|
||||
peut lever TypeError ou ScoFormatError
|
||||
L'objet créé est ajouté et commité.
|
||||
Résultat: instance de ApcReferentielCompetences
|
||||
"""
|
||||
# Vérifie que le même fichier n'a pas déjà été chargé:
|
||||
|
@ -33,7 +36,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
|||
scodoc_orig_filename=orig_filename, dept_id=dept_id
|
||||
).count():
|
||||
raise ScoValueError(
|
||||
f"""Un référentiel a déjà été chargé d'un fichier de même nom.
|
||||
f"""Un référentiel a déjà été chargé d'un fichier de même nom.
|
||||
({orig_filename})
|
||||
Supprimez-le ou changez le nom du fichier."""
|
||||
)
|
||||
|
@ -41,7 +44,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
|||
try:
|
||||
root = ElementTree.XML(xml_data)
|
||||
except ElementTree.ParseError as exc:
|
||||
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}")
|
||||
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}") from exc
|
||||
if root.tag != "referentiel_competence":
|
||||
raise ScoFormatError("élément racine 'referentiel_competence' manquant")
|
||||
args = ApcReferentielCompetences.attr_from_xml(root.attrib)
|
||||
|
@ -60,7 +63,8 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
|||
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
|
||||
db.session.rollback()
|
||||
raise ScoValueError(
|
||||
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
|
||||
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({
|
||||
competence.attrib["id"]})
|
||||
"""
|
||||
) from exc
|
||||
ref.competences.append(c)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -13,22 +13,22 @@ Utilisation:
|
|||
cherche les RCUEs de l'année (BUT1, 2, 3)
|
||||
pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
|
||||
|
||||
on instancie des DecisionsProposees pour les
|
||||
on instancie des DecisionsProposees pour les
|
||||
différents éléments (UEs, RCUEs, Année, Diplôme)
|
||||
Cela donne
|
||||
Cela donne
|
||||
- les codes possibles (dans .codes)
|
||||
- le code actuel si une décision existe déjà (dans code_valide)
|
||||
- pour les UEs, le rcue s'il y en a un)
|
||||
|
||||
|
||||
2) Validation pour l'utilisateur (form)) => enregistrement code
|
||||
- on vérifie que le code soumis est bien dans les codes possibles
|
||||
- on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
|
||||
ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
|
||||
- Si RCUE validé, on déclenche d'éventuelles validations:
|
||||
("La validation des deux UE du niveau d'une compétence emporte la validation
|
||||
- Si RCUE validé, on déclenche d'éventuelles validations:
|
||||
("La validation des deux UE du niveau d'une compétence emporte la validation
|
||||
de l'ensemble des UE du niveau inférieur de cette même compétence.")
|
||||
|
||||
Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
|
||||
|
||||
Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
|
||||
autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
|
||||
Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
|
||||
- autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
|
||||
|
@ -39,8 +39,8 @@ Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
|
|||
Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
|
||||
Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
|
||||
|
||||
La soumission du formulaire:
|
||||
- etud, formation
|
||||
La soumission du formulaire:
|
||||
- etud, formation
|
||||
- UEs: [(formsemestre, ue, code), ...]
|
||||
- RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
|
||||
(S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
|
||||
|
@ -77,7 +77,7 @@ from app.models.but_refcomp import (
|
|||
ApcNiveau,
|
||||
ApcParcours,
|
||||
)
|
||||
from app.models import Scolog, ScolarAutorisationInscription
|
||||
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription
|
||||
from app.models.but_validations import (
|
||||
ApcValidationAnnee,
|
||||
ApcValidationRCUE,
|
||||
|
@ -117,7 +117,7 @@ class NoRCUEError(ScoValueError):
|
|||
{warning_impair}
|
||||
{warning_pair}
|
||||
<div><b>UE {ue.acronyme}</b>: niveau {html.escape(str(ue.niveau_competence))}</div>
|
||||
<div><b>UEs impaires:</b> {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
|
||||
<div><b>UEs impaires:</b> {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
|
||||
for u in deca.ues_impair))}
|
||||
</div>
|
||||
"""
|
||||
|
@ -260,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
else []
|
||||
)
|
||||
# ---- Niveaux et RCUEs
|
||||
niveaux_by_parcours = (
|
||||
formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
|
||||
self.annee_but, [self.parcour] if self.parcour else None
|
||||
)[1]
|
||||
)
|
||||
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
|
||||
self.annee_but, [self.parcour] if self.parcour else None
|
||||
)[
|
||||
1
|
||||
]
|
||||
self.niveaux_competences = niveaux_by_parcours["TC"] + (
|
||||
niveaux_by_parcours[self.parcour.id] if self.parcour else []
|
||||
)
|
||||
|
@ -273,8 +273,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
= niveaux du tronc commun + niveau du parcours de l'étudiant.
|
||||
"""
|
||||
self.rcue_by_niveau = self._compute_rcues_annee()
|
||||
"""RCUEs de l'année
|
||||
(peuvent être construits avec des UEs validées antérieurement: redoublants
|
||||
"""RCUEs de l'année
|
||||
(peuvent être construits avec des UEs validées antérieurement: redoublants
|
||||
avec UEs capitalisées, validation "antérieures")
|
||||
"""
|
||||
# ---- Décision année et autorisation
|
||||
|
@ -358,13 +358,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# self.codes = [] # pas de décision annuelle sur semestres impairs
|
||||
elif self.inscription_etat != scu.INSCRIT:
|
||||
self.codes = [
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat == scu.DEMISSION
|
||||
else sco_codes.DEF,
|
||||
(
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
),
|
||||
# propose aussi d'autres codes, au cas où...
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat != scu.DEMISSION
|
||||
else sco_codes.DEF,
|
||||
(
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat != scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
),
|
||||
sco_codes.ABAN,
|
||||
sco_codes.ABL,
|
||||
sco_codes.EXCLU,
|
||||
|
@ -380,14 +384,24 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
sco_codes.ADJ,
|
||||
] + self.codes
|
||||
explanation += f" et {self.nb_rcues_under_8} < 8"
|
||||
else:
|
||||
self.codes = [
|
||||
sco_codes.RED,
|
||||
sco_codes.NAR,
|
||||
sco_codes.PAS1NCI,
|
||||
sco_codes.ADJ,
|
||||
sco_codes.PASD, # voir #488 (discutable, conventions locales)
|
||||
] + self.codes
|
||||
else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue:
|
||||
if formsemestre.semestre_id % 2 and self.formsemestre_pair is None:
|
||||
# Si jury sur un seul semestre impair, ne propose pas redoublement
|
||||
# et efface décision éventuellement existante
|
||||
codes = [None]
|
||||
else:
|
||||
codes = []
|
||||
self.codes = (
|
||||
codes
|
||||
+ [
|
||||
sco_codes.RED,
|
||||
sco_codes.NAR,
|
||||
sco_codes.PAS1NCI,
|
||||
sco_codes.ADJ,
|
||||
sco_codes.PASD, # voir #488 (discutable, conventions locales)
|
||||
]
|
||||
+ self.codes
|
||||
)
|
||||
explanation += f""" et {self.nb_rcues_under_8
|
||||
} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
|
||||
|
||||
|
@ -399,15 +413,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# Si validée par niveau supérieur:
|
||||
if self.code_valide == sco_codes.ADSUP:
|
||||
self.codes.insert(0, sco_codes.ADSUP)
|
||||
self.explanation = f"<div>{explanation}</div>"
|
||||
self.explanation = f'<div class="deca-expl">{explanation}</div>'
|
||||
messages = self.descr_pb_coherence()
|
||||
if messages:
|
||||
self.explanation += (
|
||||
'<div class="warning">'
|
||||
+ '</div><div class="warning">'.join(messages)
|
||||
'<div class="warning warning-info">'
|
||||
+ '</div><div class="warning warning-info">'.join(messages)
|
||||
+ "</div>"
|
||||
)
|
||||
self.codes = [self.codes[0]] + sorted(self.codes[1:])
|
||||
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
|
||||
|
||||
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
|
||||
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
|
||||
|
@ -514,19 +528,21 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
"""Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
|
||||
du niveau auquel appartient formsemestre.
|
||||
|
||||
-> S_impair, S_pair
|
||||
-> S_impair, S_pair (de la même année scolaire)
|
||||
|
||||
Si l'origine est impair, S_impair est l'origine et S_pair est None
|
||||
Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
|
||||
suivi par cet étudiant (ou None).
|
||||
|
||||
Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
|
||||
"""
|
||||
if not formsemestre.formation.is_apc(): # garde fou
|
||||
return None, None
|
||||
|
||||
if formsemestre.semestre_id % 2:
|
||||
idx_autre = formsemestre.semestre_id + 1
|
||||
idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant
|
||||
else:
|
||||
idx_autre = formsemestre.semestre_id - 1
|
||||
idx_autre = formsemestre.semestre_id - 1 # pair: autre = précédent
|
||||
|
||||
# Cherche l'autre semestre de la même année scolaire:
|
||||
autre_formsemestre = None
|
||||
|
@ -539,6 +555,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
inscr.formsemestre.formation.referentiel_competence
|
||||
== formsemestre.formation.referentiel_competence
|
||||
)
|
||||
# Non bloqué
|
||||
and not inscr.formsemestre.block_moyennes
|
||||
# L'autre semestre
|
||||
and (inscr.formsemestre.semestre_id == idx_autre)
|
||||
# de la même année scolaire
|
||||
|
@ -581,11 +599,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# Ordonne par numéro d'UE
|
||||
niv_rcue = sorted(
|
||||
self.rcue_by_niveau.items(),
|
||||
key=lambda x: x[1].ue_1.numero
|
||||
if x[1].ue_1
|
||||
else x[1].ue_2.numero
|
||||
if x[1].ue_2
|
||||
else 0,
|
||||
key=lambda x: (
|
||||
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
|
||||
),
|
||||
)
|
||||
return {
|
||||
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
|
||||
|
@ -610,6 +626,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
def next_semestre_ids(self, code: str) -> set[int]:
|
||||
"""Les indices des semestres dans lequels l'étudiant est autorisé
|
||||
à poursuivre après le semestre courant.
|
||||
code: code jury sur année BUT
|
||||
"""
|
||||
# La poursuite d'études dans un semestre pair d'une même année
|
||||
# est de droit pour tout étudiant.
|
||||
|
@ -653,6 +670,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
|
||||
Si les code_rcue et le code_annee ne sont pas fournis,
|
||||
et qu'il n'y en a pas déjà, enregistre ceux par défaut.
|
||||
|
||||
Si le code_annee est None, efface le code déjà enregistré.
|
||||
"""
|
||||
log("jury_but.DecisionsProposeesAnnee.record_form")
|
||||
code_annee = self.codes[0] # si pas dans le form, valeur par defaut
|
||||
|
@ -697,6 +716,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
def record(self, code: str, mark_recorded: bool = True) -> bool:
|
||||
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
|
||||
Si l'étudiant est DEM ou DEF, ne fait rien.
|
||||
Si le code est None, efface le code déjà enregistré.
|
||||
Si mark_recorded est vrai, positionne self.recorded
|
||||
"""
|
||||
if self.inscription_etat != scu.INSCRIT:
|
||||
|
@ -746,7 +766,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
return True
|
||||
|
||||
def record_autorisation_inscription(self, code: str):
|
||||
"""Autorisation d'inscription dans semestre suivant"""
|
||||
"""Autorisation d'inscription dans semestre suivant.
|
||||
code: code jury sur année BUT
|
||||
"""
|
||||
if self.autorisations_recorded:
|
||||
return
|
||||
if self.inscription_etat != scu.INSCRIT:
|
||||
|
@ -774,16 +796,33 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
if self.formsemestre_pair is not None:
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
|
||||
|
||||
def has_notes_en_attente(self) -> bool:
|
||||
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
|
||||
res = (
|
||||
def _get_current_res(self) -> ResultatsSemestreBUT:
|
||||
"Les res. du semestre d'origine du deca"
|
||||
return (
|
||||
self.res_pair
|
||||
if self.formsemestre_pair
|
||||
and (self.formsemestre.id == self.formsemestre_pair.id)
|
||||
else self.res_impair
|
||||
)
|
||||
|
||||
def has_notes_en_attente(self) -> bool:
|
||||
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
|
||||
res = self._get_current_res()
|
||||
return res and self.etud.id in res.get_etudids_attente()
|
||||
|
||||
def get_modimpls_attente(self) -> list[ModuleImpl]:
|
||||
"Liste des ModuleImpl dans lesquels l'étudiant à au moins une note en ATTente"
|
||||
res = self._get_current_res()
|
||||
modimpls_results = [
|
||||
modimpl_result
|
||||
for modimpl_result in res.modimpls_results.values()
|
||||
if self.etud.id in modimpl_result.etudids_attente
|
||||
]
|
||||
modimpls = [
|
||||
db.session.get(ModuleImpl, mr.moduleimpl_id) for mr in modimpls_results
|
||||
]
|
||||
return sorted(modimpls, key=lambda mi: (mi.module.numero, mi.module.code))
|
||||
|
||||
def record_all(self, only_validantes: bool = False) -> bool:
|
||||
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
|
||||
et sont donc en mode "automatique".
|
||||
|
@ -796,9 +835,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
Return: True si au moins un code modifié et enregistré.
|
||||
"""
|
||||
modif = False
|
||||
# Vérification notes en attente dans formsemestre origine
|
||||
if only_validantes and self.has_notes_en_attente():
|
||||
return False
|
||||
if only_validantes:
|
||||
if self.has_notes_en_attente():
|
||||
# notes en attente dans formsemestre origine
|
||||
return False
|
||||
if Evaluation.get_evaluations_blocked_for_etud(
|
||||
self.formsemestre, self.etud
|
||||
):
|
||||
# évaluation(s) qui seront débloquées dans le futur
|
||||
return False
|
||||
|
||||
# Toujours valider dans l'ordre UE, RCUE, Année
|
||||
annee_scolaire = self.formsemestre.annee_scolaire()
|
||||
|
@ -969,19 +1014,23 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
if dec_ue.code_valide not in CODES_UE_VALIDES:
|
||||
if (
|
||||
dec_ue.ue_status
|
||||
and dec_ue.ue_status["was_capitalized"]
|
||||
and dec_ue.ue_status["is_capitalized"]
|
||||
):
|
||||
messages.append(
|
||||
f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
|
||||
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est (probablement une validation antérieure)"
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
f"L'UE {ue.acronyme} n'a pas décision (???)"
|
||||
)
|
||||
# Voyons si on est dispensé de cette ue ?
|
||||
res = self.res_impair if ue.semestre_idx % 2 else self.res_pair
|
||||
if res and (self.etud.id, ue.id) in res.dispense_ues:
|
||||
messages.append(f"Pas (ré)inscrit à l'UE {ue.acronyme}")
|
||||
return messages
|
||||
|
||||
def valide_diplome(self) -> bool:
|
||||
|
@ -1468,9 +1517,11 @@ class DecisionsProposeesUE(DecisionsProposees):
|
|||
self.validation = None # cache toute validation
|
||||
self.explanation = "non inscrit (dem. ou déf.)"
|
||||
self.codes = [
|
||||
sco_codes.DEM
|
||||
if res.get_etud_etat(etud.id) == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
(
|
||||
sco_codes.DEM
|
||||
if res.get_etud_etat(etud.id) == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
)
|
||||
]
|
||||
return
|
||||
|
||||
|
@ -1484,7 +1535,7 @@ class DecisionsProposeesUE(DecisionsProposees):
|
|||
|
||||
def __repr__(self) -> str:
|
||||
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
|
||||
} codes={self.codes} explanation={self.explanation}>"""
|
||||
} codes={self.codes} explanation="{self.explanation}">"""
|
||||
|
||||
def compute_codes(self):
|
||||
"""Calcul des .codes attribuables et de l'explanation associée"""
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -55,11 +55,21 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
|
|||
else:
|
||||
line_sep = "\n"
|
||||
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
|
||||
|
||||
if fmt.startswith("xls"):
|
||||
titles.update(
|
||||
{
|
||||
"etudid": "etudid",
|
||||
"code_nip": "nip",
|
||||
"code_ine": "ine",
|
||||
"ects_but": "Total ECTS BUT",
|
||||
"civilite": "Civ.",
|
||||
"nom": "Nom",
|
||||
"prenom": "Prénom",
|
||||
}
|
||||
)
|
||||
# Style excel... passages à la ligne sur \n
|
||||
xls_style_base = sco_excel.excel_make_style()
|
||||
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
|
||||
|
||||
tab = GenTable(
|
||||
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
|
||||
caption=title,
|
||||
|
@ -69,7 +79,7 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
|
|||
html_title=f"""<div style="margin-bottom: 8px;"><span
|
||||
style="font-size: 120%; font-weight: bold;">{title}</span>
|
||||
<span style="padding-left: 20px;">
|
||||
<a href="{url_for("notes.pvjury_page_but",
|
||||
<a href="{url_for("notes.pvjury_page_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
|
||||
class="stdlink">version excel</a></span></div>
|
||||
|
||||
|
@ -116,7 +126,7 @@ def pvjury_table_but(
|
|||
"pas de référentiel de compétences associé à la formation de ce semestre !"
|
||||
)
|
||||
titles = {
|
||||
"nom": "Code" if anonymous else "Nom",
|
||||
"nom_pv": "Code" if anonymous else "Nom",
|
||||
"cursus": "Cursus",
|
||||
"ects": "ECTS",
|
||||
"ues": "UE validées",
|
||||
|
@ -144,33 +154,47 @@ def pvjury_table_but(
|
|||
except ScoValueError:
|
||||
deca = None
|
||||
|
||||
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
|
||||
row = {
|
||||
"nom": etud.code_ine or etud.code_nip or etud.id
|
||||
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
|
||||
else etud.etat_civil_pv(
|
||||
line_sep=line_sep, with_paragraph=with_paragraph_nom
|
||||
"nom_pv": (
|
||||
etud.code_ine or etud.code_nip or etud.id
|
||||
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
|
||||
else etud.etat_civil_pv(
|
||||
line_sep=line_sep, with_paragraph=with_paragraph_nom
|
||||
)
|
||||
),
|
||||
"_nom_order": etud.sort_key,
|
||||
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
|
||||
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
|
||||
"_nom_target": url_for(
|
||||
"scolar.ficheEtud",
|
||||
"_nom_pv_order": etud.sort_key,
|
||||
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"',
|
||||
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"',
|
||||
"_nom_pv_target": url_for(
|
||||
"scolar.fiche_etud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
),
|
||||
"cursus": _descr_cursus_but(etud),
|
||||
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
|
||||
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
|
||||
"_ects_xls": deca.ects_annee(),
|
||||
"ects_but": ects_but_valides,
|
||||
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
|
||||
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
|
||||
if deca
|
||||
else "-",
|
||||
"niveaux": (
|
||||
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-"
|
||||
),
|
||||
"decision_but": deca.code_valide if deca else "",
|
||||
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
|
||||
if deca
|
||||
else "",
|
||||
"devenir": (
|
||||
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
|
||||
if deca
|
||||
else ""
|
||||
),
|
||||
# pour exports excel seulement:
|
||||
"civilite": etud.civilite_etat_civil_str,
|
||||
"nom": etud.nom,
|
||||
"prenom": etud.prenom_etat_civil or etud.prenom or "",
|
||||
"etudid": etud.id,
|
||||
"code_nip": etud.code_nip,
|
||||
"code_ine": etud.code_ine,
|
||||
}
|
||||
if deca.valide_diplome() or not only_diplome:
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda x: x["_nom_order"])
|
||||
rows.sort(key=lambda x: x["_nom_pv_order"])
|
||||
return rows, titles
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -16,8 +16,8 @@ from app.scodoc.sco_exceptions import ScoValueError
|
|||
|
||||
|
||||
def formsemestre_validation_auto_but(
|
||||
formsemestre: FormSemestre, only_adm: bool = True
|
||||
) -> int:
|
||||
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
|
||||
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
|
||||
"""Calcul automatique des décisions de jury sur une "année" BUT.
|
||||
|
||||
- N'enregistre jamais de décisions de l'année scolaire précédente, même
|
||||
|
@ -27,16 +27,22 @@ def formsemestre_validation_auto_but(
|
|||
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
|
||||
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
|
||||
|
||||
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
|
||||
Returns:
|
||||
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
|
||||
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
|
||||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
raise ScoValueError("fonction réservée aux formations BUT")
|
||||
nb_etud_modif = 0
|
||||
decas = []
|
||||
with sco_cache.DeferredSemCacheManager():
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
etud = Identite.get_etud(etudid)
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
nb_etud_modif += deca.record_all(only_validantes=only_adm)
|
||||
if not dry_run:
|
||||
nb_etud_modif += deca.record_all(only_validantes=only_adm)
|
||||
else:
|
||||
decas.append(deca)
|
||||
|
||||
db.session.commit()
|
||||
ScolarNews.add(
|
||||
|
@ -49,4 +55,4 @@ def formsemestre_validation_auto_but(
|
|||
formsemestre_id=formsemestre.id,
|
||||
),
|
||||
)
|
||||
return nb_etud_modif
|
||||
return nb_etud_modif, decas
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -21,8 +21,6 @@ from app.but.jury_but import (
|
|||
DecisionsProposeesRCUE,
|
||||
DecisionsProposeesUE,
|
||||
)
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import (
|
||||
ApcNiveau,
|
||||
FormSemestre,
|
||||
|
@ -33,11 +31,8 @@ from app.models import (
|
|||
ScolarFormSemestreValidation,
|
||||
ScolarNews,
|
||||
)
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -76,9 +71,9 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
f"""
|
||||
<div class="titre_niveaux">
|
||||
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
|
||||
<a style="margin-left: 32px;" class="stdlink" target="_blank" rel="noopener noreferrer"
|
||||
<a style="margin-left: 32px;" class="stdlink" target="_blank" rel="noopener noreferrer"
|
||||
href={
|
||||
url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept,
|
||||
url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept,
|
||||
etudid=deca.etud.id,
|
||||
formsemestre_id=formsemestre_2.id if formsemestre_2 else formsemestre_1.id
|
||||
)
|
||||
|
@ -97,7 +92,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
|
||||
if formsemestre_2 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">RCUE</div>
|
||||
<div class="titre" title="Décisions sur RCUEs enregistrées sur l'ensemble du cursus">RCUE</div>
|
||||
"""
|
||||
)
|
||||
for dec_rcue in deca.get_decisions_rcues_annee():
|
||||
|
@ -109,23 +104,32 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
</div>"""
|
||||
)
|
||||
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
|
||||
# Les UEs à afficher,
|
||||
# qui
|
||||
ues_ro = [
|
||||
# Les UEs à afficher : on regarde si read only et si dispense (non ré-inscription à l'UE)
|
||||
# tuples (UniteEns, read_only, dispense)
|
||||
ues_ro_dispense = [
|
||||
(
|
||||
ue_impair,
|
||||
rcue.ue_cur_impair is None,
|
||||
deca.res_impair
|
||||
and ue_impair
|
||||
and (deca.etud.id, ue_impair.id) in deca.res_impair.dispense_ues,
|
||||
),
|
||||
(
|
||||
ue_pair,
|
||||
rcue.ue_cur_pair is None,
|
||||
deca.res_pair
|
||||
and ue_pair
|
||||
and (deca.etud.id, ue_pair.id) in deca.res_pair.dispense_ues,
|
||||
),
|
||||
]
|
||||
# Ordonne selon les dates des 2 semestres considérés:
|
||||
if reverse_semestre:
|
||||
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
|
||||
ues_ro_dispense[0], ues_ro_dispense[1] = (
|
||||
ues_ro_dispense[1],
|
||||
ues_ro_dispense[0],
|
||||
)
|
||||
# Colonnes d'UE:
|
||||
for ue, ue_read_only in ues_ro:
|
||||
for ue, ue_read_only, ue_dispense in ues_ro_dispense:
|
||||
if ue:
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
|
@ -134,6 +138,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
disabled=read_only or ue_read_only,
|
||||
annee_prec=ue_read_only,
|
||||
niveau_id=ue.niveau_competence.id,
|
||||
ue_dispense=ue_dispense,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
@ -172,7 +177,7 @@ def _gen_but_select(
|
|||
]
|
||||
)
|
||||
return f"""<select required name="{name}"
|
||||
class="but_code {klass}"
|
||||
class="but_code {klass}"
|
||||
data-orig_code="{code_valide or (codes[0] if codes else '')}"
|
||||
data-orig_recorded="{code_valide or ''}"
|
||||
onchange="change_menu_code(this);"
|
||||
|
@ -188,21 +193,30 @@ def _gen_but_niveau_ue(
|
|||
disabled: bool = False,
|
||||
annee_prec: bool = False,
|
||||
niveau_id: int = None,
|
||||
ue_dispense: bool = False,
|
||||
) -> str:
|
||||
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
|
||||
moy_ue_str = f"""<span class="ue_cap">{
|
||||
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
|
||||
|
||||
if ue_dispense:
|
||||
etat_en_cours = """Non (ré)inscrit à cette UE"""
|
||||
else:
|
||||
etat_en_cours = f"""UE en cours
|
||||
{ "sans notes" if np.isnan(dec_ue.moy_ue)
|
||||
else
|
||||
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
|
||||
}
|
||||
"""
|
||||
|
||||
scoplement = f"""<div class="scoplement">
|
||||
<div>
|
||||
<b>UE {ue.acronyme} capitalisée </b>
|
||||
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
|
||||
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
|
||||
</span>
|
||||
</div>
|
||||
<div>UE en cours
|
||||
{ "sans notes" if np.isnan(dec_ue.moy_ue)
|
||||
else
|
||||
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
|
||||
}
|
||||
<div>
|
||||
{ etat_en_cours }
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
@ -214,7 +228,7 @@ def _gen_but_niveau_ue(
|
|||
<div>
|
||||
<b>UE {ue.acronyme} antérieure </b>
|
||||
<span>validée {dec_ue.validation.code}
|
||||
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
|
||||
le {dec_ue.validation.event_date.strftime(scu.DATE_FMT)}
|
||||
</span>
|
||||
</div>
|
||||
<div>Non reprise dans l'année en cours</div>
|
||||
|
@ -232,9 +246,7 @@ def _gen_but_niveau_ue(
|
|||
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
|
||||
if dec_ue.code_valide:
|
||||
date_str = (
|
||||
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
|
||||
à {dec_ue.validation.event_date.strftime("%Hh%M")}
|
||||
"""
|
||||
f"""enregistré le {dec_ue.validation.event_date.strftime(scu.DATEATIME_FMT)}"""
|
||||
if dec_ue.validation and dec_ue.validation.event_date
|
||||
else ""
|
||||
)
|
||||
|
@ -244,7 +256,13 @@ def _gen_but_niveau_ue(
|
|||
</div>
|
||||
"""
|
||||
else:
|
||||
scoplement = ""
|
||||
if dec_ue.ue_status and dec_ue.ue_status["was_capitalized"]:
|
||||
scoplement = """<div class="scoplement">
|
||||
UE déjà capitalisée avec résultat moins favorable.
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
scoplement = ""
|
||||
|
||||
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
|
||||
if dec_ue.code_valide is not None and dec_ue.codes:
|
||||
|
@ -256,20 +274,20 @@ def _gen_but_niveau_ue(
|
|||
return f"""<div class="but_niveau_ue {ue_class}
|
||||
{'annee_prec' if annee_prec else ''}
|
||||
">
|
||||
<div title="{ue.titre}">{ue.acronyme}</div>
|
||||
<div title="{ue.titre or ''}">{ue.acronyme}</div>
|
||||
<div class="but_note with_scoplement">
|
||||
<div>{moy_ue_str}</div>
|
||||
{scoplement}
|
||||
</div>
|
||||
<div class="but_code">{
|
||||
_gen_but_select("code_ue_"+str(ue.id),
|
||||
_gen_but_select("code_ue_"+str(ue.id),
|
||||
dec_ue.codes,
|
||||
dec_ue.code_valide,
|
||||
disabled=disabled,
|
||||
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
|
||||
)
|
||||
}</div>
|
||||
|
||||
|
||||
</div>"""
|
||||
|
||||
|
||||
|
@ -331,250 +349,6 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def jury_but_semestriel(
|
||||
formsemestre: FormSemestre,
|
||||
etud: Identite,
|
||||
read_only: bool,
|
||||
navigation_div: str = "",
|
||||
) -> str:
|
||||
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
|
||||
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
|
||||
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
|
||||
inscription_etat = etud.inscription_etat(formsemestre.id)
|
||||
semestre_terminal = (
|
||||
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
|
||||
)
|
||||
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
|
||||
etudid=etud.id,
|
||||
origin_formsemestre_id=formsemestre.id,
|
||||
).all()
|
||||
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
|
||||
# ou si décision déjà enregistrée:
|
||||
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
|
||||
formsemestre.semestre_id + 1
|
||||
) in (a.semestre_id for a in autorisations_passage)
|
||||
decisions_ues = {
|
||||
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
|
||||
for ue in ues
|
||||
}
|
||||
for dec_ue in decisions_ues.values():
|
||||
dec_ue.compute_codes()
|
||||
|
||||
if request.method == "POST":
|
||||
if not read_only:
|
||||
for key in request.form:
|
||||
code = request.form[key]
|
||||
# Codes d'UE
|
||||
code_match = re.match(r"^code_ue_(\d+)$", key)
|
||||
if code_match:
|
||||
ue_id = int(code_match.group(1))
|
||||
dec_ue = decisions_ues.get(ue_id)
|
||||
if not dec_ue:
|
||||
raise ScoValueError(f"UE invalide ue_id={ue_id}")
|
||||
dec_ue.record(code)
|
||||
db.session.commit()
|
||||
flash("codes enregistrés")
|
||||
if not semestre_terminal:
|
||||
if request.form.get("autorisation_passage"):
|
||||
if not formsemestre.semestre_id + 1 in (
|
||||
a.semestre_id for a in autorisations_passage
|
||||
):
|
||||
ScolarAutorisationInscription.delete_autorisation_etud(
|
||||
etud.id, formsemestre.id
|
||||
)
|
||||
ScolarAutorisationInscription.autorise_etud(
|
||||
etud.id,
|
||||
formsemestre.formation.formation_code,
|
||||
formsemestre.id,
|
||||
formsemestre.semestre_id + 1,
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"""autorisation de passage en S{formsemestre.semestre_id + 1
|
||||
} enregistrée"""
|
||||
)
|
||||
else:
|
||||
if est_autorise_a_passer:
|
||||
ScolarAutorisationInscription.delete_autorisation_etud(
|
||||
etud.id, formsemestre.id
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
|
||||
)
|
||||
ScolarNews.add(
|
||||
typ=ScolarNews.NEWS_JURY,
|
||||
obj=formsemestre.id,
|
||||
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
|
||||
url=url_for(
|
||||
"notes.formsemestre_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre.id,
|
||||
),
|
||||
)
|
||||
return flask.redirect(
|
||||
url_for(
|
||||
"notes.formsemestre_validation_but",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
)
|
||||
)
|
||||
# GET
|
||||
if formsemestre.semestre_id % 2 == 0:
|
||||
warning = f"""<div class="warning">
|
||||
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
|
||||
en jury BUT annuel car il lui manque le semestre précédent.
|
||||
</div>"""
|
||||
else:
|
||||
warning = ""
|
||||
H = [
|
||||
html_sco_header.sco_header(
|
||||
page_title=f"Validation BUT S{formsemestre.semestre_id}",
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
cssstyles=("css/jury_but.css",),
|
||||
javascripts=("js/jury_but.js",),
|
||||
),
|
||||
f"""
|
||||
<div class="jury_but">
|
||||
<div>
|
||||
<div class="bull_head">
|
||||
<div>
|
||||
<div class="titre_parcours">Jury BUT S{formsemestre.id}
|
||||
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
|
||||
</div>
|
||||
<div class="nom_etud">{etud.nomprenom}</div>
|
||||
</div>
|
||||
<div class="bull_photo"><a href="{
|
||||
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
|
||||
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
|
||||
{warning}
|
||||
</div>
|
||||
|
||||
<form method="post" class="jury_but_box" id="jury_but">
|
||||
""",
|
||||
]
|
||||
|
||||
erase_span = ""
|
||||
if not read_only:
|
||||
# Requête toutes les validations (pas seulement celles du deca courant),
|
||||
# au cas où: changement d'architecture, saisie en mode classique, ...
|
||||
validations = ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etud.id, formsemestre_id=formsemestre.id
|
||||
).all()
|
||||
if validations:
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id, only_one_sem=1)
|
||||
}" class="stdlink">effacer les décisions enregistrées</a>"""
|
||||
else:
|
||||
erase_span = (
|
||||
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
|
||||
)
|
||||
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_section_annee">
|
||||
</div>
|
||||
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
|
||||
"""
|
||||
)
|
||||
if not ues:
|
||||
H.append(
|
||||
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
|
||||
formation, et l'association UEs / Niveaux de compétences</div>"""
|
||||
)
|
||||
else:
|
||||
H.append(
|
||||
"""
|
||||
<div class="but_annee">
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
"""
|
||||
)
|
||||
for ue in ues:
|
||||
dec_ue = decisions_ues[ue.id]
|
||||
H.append("""<div class="but_niveau_titre"><div></div></div>""")
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
ue,
|
||||
dec_ue,
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
H.append(
|
||||
"""<div style=""></div>
|
||||
<div class=""></div>"""
|
||||
)
|
||||
H.append("</div>") # but_annee
|
||||
|
||||
div_autorisations_passage = (
|
||||
f"""
|
||||
<div class="but_autorisations_passage">
|
||||
<span>Autorisé à passer en :</span>
|
||||
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
|
||||
</div>
|
||||
"""
|
||||
if autorisations_passage
|
||||
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
|
||||
)
|
||||
H.append(div_autorisations_passage)
|
||||
|
||||
if read_only:
|
||||
H.append(
|
||||
f"""<div class="but_explanation">
|
||||
{"Vous n'avez pas la permission de modifier ces décisions."
|
||||
if formsemestre.etat
|
||||
else "Semestre verrouillé."}
|
||||
Les champs entourés en vert sont enregistrés.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_settings">
|
||||
<input type="checkbox" name="autorisation_passage" value="1" {
|
||||
"checked" if est_autorise_a_passer else ""}>
|
||||
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
|
||||
</input>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
H.append("""<div class="help">dernier semestre de la formation.</div>""")
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_buttons">
|
||||
<span><input type="submit" value="Enregistrer ces décisions"></span>
|
||||
<span>{erase_span}</span>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
H.append(navigation_div)
|
||||
H.append("</div>")
|
||||
H.append(
|
||||
render_template(
|
||||
"but/documentation_codes_jury.j2",
|
||||
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
|
||||
or sco_preferences.get_preference("UnivName")
|
||||
or "Apogée"}""",
|
||||
codes=ScoDocSiteConfig.get_codes_apo_dict(),
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
# -------------
|
||||
def infos_fiche_etud_html(etudid: int) -> str:
|
||||
"""Section html pour fiche etudiant
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -75,7 +75,7 @@ class RegroupementCoherentUE:
|
|||
else None
|
||||
)
|
||||
|
||||
# Autres validations pour l'UE paire
|
||||
# Autres validations pour les UEs paire/impaire
|
||||
self.validation_ue_best_pair = best_autre_ue_validation(
|
||||
etud.id,
|
||||
niveau.id,
|
||||
|
@ -101,14 +101,24 @@ class RegroupementCoherentUE:
|
|||
"résultats formsemestre de l'UE si elle est courante, None sinon"
|
||||
self.ue_status_impair = None
|
||||
if self.ue_cur_impair:
|
||||
# UE courante
|
||||
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
|
||||
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
|
||||
self.ue_1 = self.ue_cur_impair
|
||||
self.res_impair = res_impair
|
||||
self.ue_status_impair = ue_status
|
||||
elif self.validation_ue_best_impair:
|
||||
# UE capitalisée
|
||||
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
|
||||
self.ue_1 = self.validation_ue_best_impair.ue
|
||||
if (
|
||||
res_impair
|
||||
and self.validation_ue_best_impair
|
||||
and self.validation_ue_best_impair.ue
|
||||
):
|
||||
self.ue_status_impair = res_impair.get_etud_ue_status(
|
||||
etud.id, self.validation_ue_best_impair.ue.id
|
||||
)
|
||||
else:
|
||||
self.moy_ue_1, self.ue_1 = None, None
|
||||
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -30,7 +30,9 @@ class StatsMoyenne:
|
|||
self.max = np.nanmax(vals)
|
||||
self.size = len(vals)
|
||||
self.nb_vals = self.size - np.count_nonzero(np.isnan(vals))
|
||||
except TypeError: # que des NaN dans un array d'objets, ou ce genre de choses exotiques...
|
||||
except (
|
||||
TypeError
|
||||
): # que des NaN dans un array d'objets, ou ce genre de choses exotiques...
|
||||
self.moy = self.min = self.max = self.size = self.nb_vals = 0
|
||||
|
||||
def to_dict(self):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -667,10 +667,12 @@ class BonusCalais(BonusSportAdditif):
|
|||
sur 20 obtenus dans chacune des matières optionnelles sont cumulés
|
||||
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
|
||||
<ul>
|
||||
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
|
||||
<li><b>en BUT</b> à la moyenne de chaque UE;
|
||||
</li>
|
||||
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
|
||||
(ex : UE2.1BS, UE32BS)
|
||||
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant;
|
||||
</li>
|
||||
<li><b>en LP</b>, et en BUT avant 2023-2024, à la moyenne de chaque UE dont
|
||||
l'acronyme termine par <b>BS</b> (comme UE2.1BS, UE32BS).
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
|
@ -692,12 +694,17 @@ class BonusCalais(BonusSportAdditif):
|
|||
else:
|
||||
self.classic_use_bonus_ues = True # pour les LP
|
||||
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
|
||||
ues = self.formsemestre.get_ues(with_sport=False)
|
||||
ues_sans_bs = [
|
||||
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
|
||||
] # les 2 derniers cars forcés en majus
|
||||
for ue in ues_sans_bs:
|
||||
self.bonus_ues[ue.id] = 0.0
|
||||
if (
|
||||
self.formsemestre.annee_scolaire() < 2023
|
||||
or not self.formsemestre.formation.is_apc()
|
||||
):
|
||||
# LP et anciens semestres: ne s'applique qu'aux UE dont l'acronyme termine par BS
|
||||
ues = self.formsemestre.get_ues(with_sport=False)
|
||||
ues_sans_bs = [
|
||||
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
|
||||
] # les 2 derniers cars forcés en majus
|
||||
for ue in ues_sans_bs:
|
||||
self.bonus_ues[ue.id] = 0.0
|
||||
|
||||
|
||||
class BonusColmar(BonusSportAdditif):
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -23,6 +23,7 @@ from app.models import (
|
|||
)
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ValidationsSemestre(ResultatsCache):
|
||||
|
@ -38,7 +39,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
super().__init__(formsemestre, sco_cache.ValidationsSemestreCache)
|
||||
|
||||
self.decisions_jury = {}
|
||||
"""Décisions prises dans ce semestre:
|
||||
"""Décisions prises dans ce semestre:
|
||||
{ etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}"""
|
||||
self.decisions_jury_ues = {}
|
||||
"""Décisions sur des UEs dans ce semestre:
|
||||
|
@ -84,7 +85,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
"code": decision.code,
|
||||
"assidu": decision.assidu,
|
||||
"compense_formsemestre_id": decision.compense_formsemestre_id,
|
||||
"event_date": decision.event_date.strftime("%d/%m/%Y"),
|
||||
"event_date": decision.event_date.strftime(scu.DATE_FMT),
|
||||
}
|
||||
self.decisions_jury = decisions_jury
|
||||
|
||||
|
@ -107,7 +108,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
decisions_jury_ues[decision.etudid][decision.ue.id] = {
|
||||
"code": decision.code,
|
||||
"ects": ects, # 0. si UE non validée
|
||||
"event_date": decision.event_date.strftime("%d/%m/%Y"),
|
||||
"event_date": decision.event_date.strftime(scu.DATE_FMT),
|
||||
}
|
||||
|
||||
self.decisions_jury_ues = decisions_jury_ues
|
||||
|
@ -145,11 +146,11 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
|
|||
query = sa.text(
|
||||
"""
|
||||
SELECT DISTINCT SFV.*, ue.ue_code
|
||||
FROM
|
||||
notes_ue ue,
|
||||
FROM
|
||||
notes_ue ue,
|
||||
notes_formations nf,
|
||||
notes_formations nf2,
|
||||
scolar_formsemestre_validation SFV,
|
||||
notes_formations nf2,
|
||||
scolar_formsemestre_validation SFV,
|
||||
notes_formsemestre sem,
|
||||
notes_formsemestre_inscription ins
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,7 +35,6 @@ moyenne générale d'une UE.
|
|||
"""
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import sqlalchemy as sa
|
||||
|
@ -56,6 +55,7 @@ class EvaluationEtat:
|
|||
|
||||
evaluation_id: int
|
||||
nb_attente: int
|
||||
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
|
||||
is_complete: bool
|
||||
|
||||
def to_dict(self):
|
||||
|
@ -72,7 +72,15 @@ class ModuleImplResults:
|
|||
les caches sont gérés par ResultatsSemestre.
|
||||
"""
|
||||
|
||||
def __init__(self, moduleimpl: ModuleImpl):
|
||||
def __init__(
|
||||
self, moduleimpl: ModuleImpl, etudids: list[int], etudids_actifs: set[int]
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
- etudids : liste des etudids, qui donne l'index du dataframe
|
||||
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
|
||||
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
|
||||
"""
|
||||
self.moduleimpl_id = moduleimpl.id
|
||||
self.module_id = moduleimpl.module.id
|
||||
self.etudids = None
|
||||
|
@ -105,14 +113,21 @@ class ModuleImplResults:
|
|||
"""
|
||||
self.evals_etudids_sans_note = {}
|
||||
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
|
||||
self.load_notes()
|
||||
self.load_notes(etudids, etudids_actifs)
|
||||
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
|
||||
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
|
||||
self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index)
|
||||
"""1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
|
||||
|
||||
def load_notes(self): # ré-écriture de df_load_modimpl_notes
|
||||
def load_notes(
|
||||
self, etudids: list[int], etudids_actifs: set[int]
|
||||
): # ré-écriture de df_load_modimpl_notes
|
||||
"""Charge toutes les notes de toutes les évaluations du module.
|
||||
Args:
|
||||
- etudids : liste des etudids, qui donne l'index du dataframe
|
||||
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
|
||||
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
|
||||
|
||||
Dataframe evals_notes
|
||||
colonnes: le nom de la colonne est l'evaluation_id (int)
|
||||
index (lignes): etudid (int)
|
||||
|
@ -135,12 +150,12 @@ class ModuleImplResults:
|
|||
qui ont des notes ATT.
|
||||
"""
|
||||
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
|
||||
self.etudids = self._etudids()
|
||||
self.etudids = etudids
|
||||
|
||||
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
|
||||
# on prend les inscrits au module ET au semestre (donc sans démissionnaires)
|
||||
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
|
||||
moduleimpl.formsemestre.etudids_actifs
|
||||
etudids_actifs
|
||||
)
|
||||
self.nb_inscrits_module = len(inscrits_module)
|
||||
|
||||
|
@ -148,19 +163,21 @@ class ModuleImplResults:
|
|||
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
|
||||
self.evaluations_completes = []
|
||||
self.evaluations_completes_dict = {}
|
||||
self.etudids_attente = set() # empty
|
||||
for evaluation in moduleimpl.evaluations:
|
||||
eval_df = self._load_evaluation_notes(evaluation)
|
||||
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
|
||||
# ou évaluation déclarée "à prise en compte immédiate"
|
||||
# Les évaluations de rattrapage et 2eme session sont toujours complètes
|
||||
|
||||
# is_complete ssi
|
||||
# tous les inscrits (non dem) au module ont une note
|
||||
# ou évaluation déclarée "à prise en compte immédiate"
|
||||
# ou rattrapage, 2eme session, bonus
|
||||
# ET pas bloquée par date (is_blocked)
|
||||
is_blocked = evaluation.is_blocked()
|
||||
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
|
||||
is_complete = (
|
||||
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
|
||||
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
|
||||
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
|
||||
or (evaluation.publish_incomplete)
|
||||
or (not etudids_sans_note)
|
||||
)
|
||||
) and not is_blocked
|
||||
self.evaluations_completes.append(is_complete)
|
||||
self.evaluations_completes_dict[evaluation.id] = is_complete
|
||||
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
|
||||
|
@ -168,25 +185,39 @@ class ModuleImplResults:
|
|||
# NULL en base => ABS (= -999)
|
||||
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
|
||||
# Ce merge ne garde que les étudiants inscrits au module
|
||||
# et met à NULL les notes non présentes
|
||||
# et met à NULL (NaN) les notes non présentes
|
||||
# (notes non saisies ou etuds non inscrits au module):
|
||||
evals_notes = evals_notes.merge(
|
||||
eval_df, how="left", left_index=True, right_index=True
|
||||
)
|
||||
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
|
||||
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
|
||||
eval_etudids_attente = set(
|
||||
eval_notes_inscr.iloc[
|
||||
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
|
||||
].index
|
||||
)
|
||||
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
|
||||
nb_notes = eval_notes_inscr.notna().sum()
|
||||
|
||||
if is_blocked:
|
||||
eval_etudids_attente = set()
|
||||
else:
|
||||
# Etudiants avec notes en attente:
|
||||
# = ceux avec note ATT
|
||||
eval_etudids_attente = set(
|
||||
eval_notes_inscr.iloc[
|
||||
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
|
||||
].index
|
||||
)
|
||||
if evaluation.publish_incomplete:
|
||||
# et en "immédiat", tous ceux sans note
|
||||
eval_etudids_attente |= etudids_sans_note
|
||||
|
||||
# Synthèse pour état du module:
|
||||
self.etudids_attente |= eval_etudids_attente
|
||||
self.evaluations_etat[evaluation.id] = EvaluationEtat(
|
||||
evaluation_id=evaluation.id,
|
||||
nb_attente=len(eval_etudids_attente),
|
||||
nb_notes=int(nb_notes),
|
||||
is_complete=is_complete,
|
||||
)
|
||||
# au moins une note en ATT dans ce modimpl:
|
||||
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
|
||||
self.en_attente = bool(self.etudids_attente)
|
||||
|
||||
# Force columns names to integers (evaluation ids)
|
||||
|
@ -219,30 +250,20 @@ class ModuleImplResults:
|
|||
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
|
||||
return eval_df
|
||||
|
||||
def _etudids(self):
|
||||
"""L'index du dataframe est la liste de tous les étudiants inscrits au semestre
|
||||
(incluant les DEM et DEF)
|
||||
"""
|
||||
return [
|
||||
inscr.etudid
|
||||
for inscr in db.session.get(
|
||||
ModuleImpl, self.moduleimpl_id
|
||||
).formsemestre.inscriptions
|
||||
]
|
||||
|
||||
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
|
||||
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
|
||||
"""Coefficients des évaluations.
|
||||
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
|
||||
sont zéro.
|
||||
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
|
||||
Résultat: 2d-array of floats, shape (nb_evals, 1)
|
||||
"""
|
||||
return (
|
||||
np.array(
|
||||
[
|
||||
e.coefficient
|
||||
if e.evaluation_type == scu.EVALUATION_NORMALE
|
||||
else 0.0
|
||||
for e in moduleimpl.evaluations
|
||||
(
|
||||
e.coefficient
|
||||
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
|
||||
else 0.0
|
||||
)
|
||||
for e in modimpl.evaluations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
@ -266,7 +287,7 @@ class ModuleImplResults:
|
|||
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
|
||||
|
||||
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
|
||||
"""Notes d'une évaulation, brutes, sous forme d'un dict
|
||||
"""Notes d'une évaluation, brutes, sous forme d'un dict
|
||||
{ etudid : valeur }
|
||||
avec les valeurs float, ou "ABS" ou EXC
|
||||
"""
|
||||
|
@ -275,7 +296,7 @@ class ModuleImplResults:
|
|||
for (etudid, x) in self.evals_notes[evaluation_id].items()
|
||||
}
|
||||
|
||||
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
|
||||
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
|
||||
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
|
||||
Rattrapage: la moyenne du module est la meilleure note entre moyenne
|
||||
des autres évals et la note eval rattrapage.
|
||||
|
@ -283,25 +304,41 @@ class ModuleImplResults:
|
|||
eval_list = [
|
||||
e
|
||||
for e in moduleimpl.evaluations
|
||||
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
|
||||
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
|
||||
]
|
||||
if eval_list:
|
||||
return eval_list[0]
|
||||
return None
|
||||
|
||||
def get_evaluation_session2(self, moduleimpl: ModuleImpl):
|
||||
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
|
||||
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
|
||||
Session 2: remplace la note de moyenne des autres évals.
|
||||
"""
|
||||
eval_list = [
|
||||
e
|
||||
for e in moduleimpl.evaluations
|
||||
if e.evaluation_type == scu.EVALUATION_SESSION2
|
||||
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
|
||||
]
|
||||
if eval_list:
|
||||
return eval_list[0]
|
||||
return None
|
||||
|
||||
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
|
||||
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
|
||||
return [
|
||||
e
|
||||
for e in modimpl.evaluations
|
||||
if e.evaluation_type == Evaluation.EVALUATION_BONUS
|
||||
]
|
||||
|
||||
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
|
||||
"""Les indices des évaluations bonus"""
|
||||
return [
|
||||
i
|
||||
for (i, e) in enumerate(modimpl.evaluations)
|
||||
if e.evaluation_type == Evaluation.EVALUATION_BONUS
|
||||
]
|
||||
|
||||
|
||||
class ModuleImplResultsAPC(ModuleImplResults):
|
||||
"Calcul des moyennes de modules à la mode BUT"
|
||||
|
@ -346,7 +383,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
# et dans dans evals_poids_etuds
|
||||
# (rappel: la comparaison est toujours false face à un NaN)
|
||||
# shape: (nb_etuds, nb_evals, nb_ues)
|
||||
poids_stacked = np.stack([evals_poids] * nb_etuds)
|
||||
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
|
||||
evals_poids_etuds = np.where(
|
||||
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
|
||||
poids_stacked,
|
||||
|
@ -354,10 +391,20 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
)
|
||||
# Calcule la moyenne pondérée sur les notes disponibles:
|
||||
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
|
||||
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etuds_moy_module = np.sum(
|
||||
evals_poids_etuds * evals_notes_stacked, axis=1
|
||||
) / np.sum(evals_poids_etuds, axis=1)
|
||||
# etuds_moy_module shape: nb_etuds x nb_ues
|
||||
|
||||
# Application des évaluations bonus:
|
||||
etuds_moy_module = self.apply_bonus(
|
||||
etuds_moy_module,
|
||||
modimpl,
|
||||
evals_poids_df,
|
||||
evals_notes_stacked,
|
||||
)
|
||||
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
eval_session2 = self.get_evaluation_session2(modimpl)
|
||||
|
@ -406,6 +453,30 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
)
|
||||
return self.etuds_moy_module
|
||||
|
||||
def apply_bonus(
|
||||
self,
|
||||
etuds_moy_module: pd.DataFrame,
|
||||
modimpl: ModuleImpl,
|
||||
evals_poids_df: pd.DataFrame,
|
||||
evals_notes_stacked: np.ndarray,
|
||||
):
|
||||
"""Ajoute les points des évaluations bonus.
|
||||
Il peut y avoir un nb quelconque d'évaluations bonus.
|
||||
Les points sont directement ajoutés (ils peuvent être négatifs).
|
||||
"""
|
||||
evals_bonus = self.get_evaluations_bonus(modimpl)
|
||||
if not evals_bonus:
|
||||
return etuds_moy_module
|
||||
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
|
||||
for evaluation in evals_bonus:
|
||||
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
|
||||
etuds_moy_module += (
|
||||
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
|
||||
)
|
||||
# Clip dans [0,20]
|
||||
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
|
||||
return etuds_moy_module
|
||||
|
||||
|
||||
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
|
||||
"""Charge poids des évaluations d'un module et retourne un dataframe
|
||||
|
@ -522,6 +593,13 @@ class ModuleImplResultsClassic(ModuleImplResults):
|
|||
evals_coefs_etuds * evals_notes_20, axis=1
|
||||
) / np.sum(evals_coefs_etuds, axis=1)
|
||||
|
||||
# Application des évaluations bonus:
|
||||
etuds_moy_module = self.apply_bonus(
|
||||
etuds_moy_module,
|
||||
modimpl,
|
||||
evals_notes_20,
|
||||
)
|
||||
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
eval_session2 = self.get_evaluation_session2(modimpl)
|
||||
if eval_session2:
|
||||
|
@ -561,3 +639,22 @@ class ModuleImplResultsClassic(ModuleImplResults):
|
|||
)
|
||||
|
||||
return self.etuds_moy_module
|
||||
|
||||
def apply_bonus(
|
||||
self,
|
||||
etuds_moy_module: np.ndarray,
|
||||
modimpl: ModuleImpl,
|
||||
evals_notes_20: np.ndarray,
|
||||
):
|
||||
"""Ajoute les points des évaluations bonus.
|
||||
Il peut y avoir un nb quelconque d'évaluations bonus.
|
||||
Les points sont directement ajoutés (ils peuvent être négatifs).
|
||||
"""
|
||||
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
|
||||
if not evals_bonus_idx:
|
||||
return etuds_moy_module
|
||||
for eval_idx in evals_bonus_idx:
|
||||
etuds_moy_module += evals_notes_20[:, eval_idx]
|
||||
# Clip dans [0,20]
|
||||
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
|
||||
return etuds_moy_module
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -89,7 +89,7 @@ def compute_sem_moys_apc_using_ects(
|
|||
flash(
|
||||
Markup(
|
||||
f"""Calcul moyenne générale impossible: ECTS des UE manquants !<br>
|
||||
(formation: <a href="{url_for("notes.ue_table",
|
||||
(formation: <a href="{url_for("notes.ue_table",
|
||||
scodoc_dept=g.scodoc_dept, formation_id=formation_id)}">{formation.get_titre_version()}</a>)"""
|
||||
)
|
||||
)
|
||||
|
@ -100,7 +100,7 @@ def compute_sem_moys_apc_using_ects(
|
|||
|
||||
|
||||
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
|
||||
"""Calcul rangs à partir d'une série ("vecteur") de notes (index etudid, valeur
|
||||
numérique) en tenant compte des ex-aequos.
|
||||
|
||||
Result: couple (tuple)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -99,9 +99,11 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
|
|||
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
|
||||
# sur toutes les UE)
|
||||
default_poids = {
|
||||
mod.id: 1.0
|
||||
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
mod.id: (
|
||||
1.0
|
||||
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
)
|
||||
for mod in modules
|
||||
}
|
||||
|
||||
|
@ -148,10 +150,12 @@ def df_load_modimpl_coefs(
|
|||
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
|
||||
# sur toutes les UE)
|
||||
default_poids = {
|
||||
modimpl.id: 1.0
|
||||
if (modimpl.module.module_type == ModuleType.STANDARD)
|
||||
and (modimpl.module.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
modimpl.id: (
|
||||
1.0
|
||||
if (modimpl.module.module_type == ModuleType.STANDARD)
|
||||
and (modimpl.module.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
)
|
||||
for modimpl in formsemestre.modimpls_sorted
|
||||
}
|
||||
|
||||
|
@ -200,8 +204,9 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
|
|||
modimpls_results = {}
|
||||
modimpls_evals_poids = {}
|
||||
modimpls_notes = []
|
||||
etudids, etudids_actifs = formsemestre.etudids_actifs()
|
||||
for modimpl in formsemestre.modimpls_sorted:
|
||||
mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
|
||||
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
|
||||
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
|
||||
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
|
||||
modimpls_results[modimpl.id] = mod_results
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -273,7 +273,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
return s.index[s.notna()]
|
||||
|
||||
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
|
||||
"""Ensemble les id des UEs que l'étudiant doit valider dans ce semestre compte tenu
|
||||
"""Ensemble des id des UEs que l'étudiant doit valider dans ce semestre compte tenu
|
||||
du parcours dans lequel il est inscrit.
|
||||
Se base sur le parcours dans ce semestre, et le référentiel de compétences.
|
||||
Note: il n'est pas nécessairement inscrit à toutes ces UEs.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
|
|||
raise ScoValueError(
|
||||
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
|
||||
impossible à déterminer pour l'étudiant <a href="{
|
||||
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
}" class="discretelink">{etud.nom_disp()}</a></p>
|
||||
<p>Il faut <a href="{
|
||||
url_for("notes.formsemestre_edit_uecoefs", scodoc_dept=g.scodoc_dept,
|
||||
|
@ -256,8 +256,9 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]
|
|||
"""
|
||||
modimpls_results = {}
|
||||
modimpls_notes = []
|
||||
etudids, etudids_actifs = formsemestre.etudids_actifs()
|
||||
for modimpl in formsemestre.modimpls_sorted:
|
||||
mod_results = moy_mod.ModuleImplResultsClassic(modimpl)
|
||||
mod_results = moy_mod.ModuleImplResultsClassic(modimpl, etudids, etudids_actifs)
|
||||
etuds_moy_module = mod_results.compute_module_moy()
|
||||
modimpls_results[modimpl.id] = mod_results
|
||||
modimpls_notes.append(etuds_moy_module)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -9,12 +9,13 @@
|
|||
|
||||
from collections import Counter, defaultdict
|
||||
from collections.abc import Generator
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
from operator import attrgetter
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import g, url_for
|
||||
|
||||
from app import db
|
||||
|
@ -22,14 +23,19 @@ from app.comp import res_sem
|
|||
from app.comp.res_cache import ResultatsCache
|
||||
from app.comp.jury import ValidationsSemestre
|
||||
from app.comp.moy_mod import ModuleImplResults
|
||||
from app.models import FormSemestre, FormSemestreUECoef
|
||||
from app.models import Identite
|
||||
from app.models import ModuleImpl, ModuleImplInscription
|
||||
from app.models import ScolarAutorisationInscription
|
||||
from app.models.ues import UniteEns
|
||||
from app.models import (
|
||||
Evaluation,
|
||||
FormSemestre,
|
||||
FormSemestreUECoef,
|
||||
Identite,
|
||||
ModuleImpl,
|
||||
ModuleImplInscription,
|
||||
ScolarAutorisationInscription,
|
||||
UniteEns,
|
||||
)
|
||||
from app.scodoc.sco_cache import ResultatsSemestreCache
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoValueError, ScoTemporaryError
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -192,16 +198,86 @@ class ResultatsSemestre(ResultatsCache):
|
|||
*[mr.etudids_attente for mr in self.modimpls_results.values()]
|
||||
)
|
||||
|
||||
# # Etat des évaluations
|
||||
# # (se substitue à do_evaluation_etat, sans les moyennes par groupes)
|
||||
# def get_evaluations_etats(evaluation_id: int) -> dict:
|
||||
# """Renvoie dict avec les clés:
|
||||
# last_modif
|
||||
# nb_evals_completes
|
||||
# nb_evals_en_cours
|
||||
# nb_evals_vides
|
||||
# attente
|
||||
# """
|
||||
# Etat des évaluations
|
||||
def get_evaluation_etat(self, evaluation: Evaluation) -> dict:
|
||||
"""État d'une évaluation
|
||||
{
|
||||
"coefficient" : float, # 0 si None
|
||||
"description" : str, # de l'évaluation, "" si None
|
||||
"etat" {
|
||||
"blocked" : bool, # vrai si prise en compte bloquée
|
||||
"evalcomplete" : bool,
|
||||
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
|
||||
"nb_notes" : int, # nb notes d'étudiants inscrits
|
||||
"nb_attente" : int, # nb de notes en ATTente (même si bloquée)
|
||||
},
|
||||
"evaluation_id" : int,
|
||||
"jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1)
|
||||
"publish_incomplete" : bool,
|
||||
}
|
||||
"""
|
||||
mod_results = self.modimpls_results.get(evaluation.moduleimpl_id)
|
||||
if mod_results is None:
|
||||
raise ScoTemporaryError() # argh !
|
||||
etat = mod_results.evaluations_etat.get(evaluation.id)
|
||||
if etat is None:
|
||||
raise ScoTemporaryError() # argh !
|
||||
# Date de dernière saisie de note
|
||||
cursor = db.session.execute(
|
||||
sa.text(
|
||||
"SELECT MAX(date) FROM notes_notes WHERE evaluation_id = :evaluation_id"
|
||||
),
|
||||
{"evaluation_id": evaluation.id},
|
||||
)
|
||||
date_modif = cursor.one_or_none()
|
||||
last_modif = date_modif[0] if date_modif else None
|
||||
return {
|
||||
"coefficient": evaluation.coefficient,
|
||||
"description": evaluation.description,
|
||||
"etat": {
|
||||
"blocked": evaluation.is_blocked(),
|
||||
"evalcomplete": etat.is_complete,
|
||||
"nb_attente": etat.nb_attente,
|
||||
"nb_notes": etat.nb_notes,
|
||||
"last_modif": last_modif,
|
||||
},
|
||||
"evaluation_id": evaluation.id,
|
||||
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
|
||||
"publish_incomplete": evaluation.publish_incomplete,
|
||||
}
|
||||
|
||||
def get_mod_evaluation_etat_list(self, modimpl: ModuleImpl) -> list[dict]:
|
||||
"""Liste des états des évaluations de ce module
|
||||
[ evaluation_etat, ... ] (voir get_evaluation_etat)
|
||||
trié par (numero desc, date_debut desc)
|
||||
"""
|
||||
# nouvelle version 2024-02-02
|
||||
return list(
|
||||
reversed(
|
||||
[
|
||||
self.get_evaluation_etat(evaluation)
|
||||
for evaluation in modimpl.evaluations
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# modernisation de get_mod_evaluation_etat_list
|
||||
# utilisé par:
|
||||
# sco_evaluations.do_evaluation_etat_in_mod
|
||||
# e["etat"]["evalcomplete"]
|
||||
# e["etat"]["nb_notes"]
|
||||
# e["etat"]["last_modif"]
|
||||
#
|
||||
# sco_formsemestre_status.formsemestre_description_table
|
||||
# "jour" (qui est e.date_debut or datetime.date(1900, 1, 1))
|
||||
# "description"
|
||||
# "coefficient"
|
||||
# e["etat"]["evalcomplete"]
|
||||
# publish_incomplete
|
||||
#
|
||||
# sco_formsemestre_status.formsemestre_tableau_modules
|
||||
# e["etat"]["nb_notes"]
|
||||
#
|
||||
|
||||
# --- JURY...
|
||||
def get_formsemestre_validations(self) -> ValidationsSemestre:
|
||||
|
@ -360,11 +436,28 @@ class ResultatsSemestre(ResultatsCache):
|
|||
ue_cap_dict["compense_formsemestre_id"] = None
|
||||
return ue_cap_dict
|
||||
|
||||
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
|
||||
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
|
||||
"""L'état de l'UE pour cet étudiant.
|
||||
Result: dict, ou None si l'UE n'est pas dans ce semestre.
|
||||
Result: dict, ou None si l'UE n'existe pas ou n'est pas dans ce semestre.
|
||||
{
|
||||
"is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure)
|
||||
"was_capitalized":# si elle a été capitalisée (meilleure ou pas)
|
||||
"is_external": # si UE externe
|
||||
"coef_ue": 0.0,
|
||||
"cur_moy_ue": 0.0, # moyenne de l'UE courante
|
||||
"moy": 0.0, # moyenne prise en compte
|
||||
"event_date": # date de la capiltalisation éventuelle (ou None)
|
||||
"ue": ue_dict, # l'UE, comme un dict
|
||||
"formsemestre_id": None,
|
||||
"capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None
|
||||
"ects_pot": 0.0, # deprecated (les ECTS liés à cette UE)
|
||||
"ects": 0.0, # les ECTS acquis grace à cette UE
|
||||
"ects_ue": # les ECTS liés à cette UE
|
||||
}
|
||||
"""
|
||||
ue: UniteEns = db.session.get(UniteEns, ue_id)
|
||||
if not ue:
|
||||
return None
|
||||
ue_dict = ue.to_dict()
|
||||
|
||||
if ue.type == UE_SPORT:
|
||||
|
@ -383,7 +476,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"ects": 0.0,
|
||||
"ects_ue": ue.ects,
|
||||
}
|
||||
if not ue_id in self.etud_moy_ue:
|
||||
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
|
||||
return None
|
||||
if not self.validations:
|
||||
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
|
||||
|
@ -440,11 +533,13 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
|
||||
"coef_ue": coef_ue,
|
||||
"ects_pot": ue.ects or 0.0,
|
||||
"ects": self.validations.decisions_jury_ues.get(etudid, {})
|
||||
.get(ue.id, {})
|
||||
.get("ects", 0.0)
|
||||
if self.validations.decisions_jury_ues
|
||||
else 0.0,
|
||||
"ects": (
|
||||
self.validations.decisions_jury_ues.get(etudid, {})
|
||||
.get(ue.id, {})
|
||||
.get("ects", 0.0)
|
||||
if self.validations.decisions_jury_ues
|
||||
else 0.0
|
||||
),
|
||||
"ects_ue": ue.ects,
|
||||
"cur_moy_ue": cur_moy_ue,
|
||||
"moy": moy_ue,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -58,7 +58,6 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
self.moy_moy = "NA"
|
||||
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
|
||||
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
|
||||
self.expr_diagnostics = ""
|
||||
self.parcours = self.formsemestre.formation.get_cursus()
|
||||
self._modimpls_dict_by_ue = {} # local cache
|
||||
|
||||
|
@ -217,9 +216,9 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
# Rangs / UEs:
|
||||
for ue in ues:
|
||||
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
|
||||
self.ue_rangs_by_group.setdefault(ue.id, {})[
|
||||
group.id
|
||||
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
|
||||
self.ue_rangs_by_group.setdefault(ue.id, {})[group.id] = (
|
||||
moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
|
||||
)
|
||||
|
||||
def get_etud_rang(self, etudid: int) -> str:
|
||||
"""Le rang (classement) de l'étudiant dans le semestre.
|
||||
|
@ -423,30 +422,37 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
)
|
||||
return evaluations
|
||||
|
||||
def get_evaluations_etats(self) -> list[dict]:
|
||||
"""Liste de toutes les évaluations du semestre
|
||||
[ {...evaluation et son etat...} ]"""
|
||||
# TODO: à moderniser (voir dans ResultatsSemestre)
|
||||
# utilisé par
|
||||
# do_evaluation_etat_in_sem
|
||||
def get_evaluations_etats(self) -> dict[int, dict]:
|
||||
""" "état" de chaque évaluation du semestre
|
||||
{
|
||||
evaluation_id : {
|
||||
"evalcomplete" : bool,
|
||||
"last_modif" : datetime | None
|
||||
"nb_notes" : int,
|
||||
}, ...
|
||||
}
|
||||
"""
|
||||
# utilisé par do_evaluation_etat_in_sem
|
||||
evaluations_etats = {}
|
||||
for modimpl in self.formsemestre.modimpls_sorted:
|
||||
for evaluation in modimpl.evaluations:
|
||||
evaluation_etat = self.get_evaluation_etat(evaluation)
|
||||
evaluations_etats[evaluation.id] = evaluation_etat["etat"]
|
||||
return evaluations_etats
|
||||
|
||||
from app.scodoc import sco_evaluations
|
||||
|
||||
if not hasattr(self, "_evaluations_etats"):
|
||||
self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
|
||||
self.formsemestre.id
|
||||
)
|
||||
|
||||
return self._evaluations_etats
|
||||
|
||||
def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
|
||||
"""Liste des états des évaluations de ce module"""
|
||||
# XXX TODO à moderniser: lent, recharge des données que l'on a déjà...
|
||||
return [
|
||||
e
|
||||
for e in self.get_evaluations_etats()
|
||||
if e["moduleimpl_id"] == moduleimpl_id
|
||||
]
|
||||
# ancienne version < 2024-02-02
|
||||
# def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
|
||||
# """Liste des états des évaluations de ce module
|
||||
# ordonnée selon (numero desc, date_debut desc)
|
||||
# """
|
||||
# # à moderniser: lent, recharge des données que l'on a déjà...
|
||||
# # remplacemé par ResultatsSemestre.get_mod_evaluation_etat_list
|
||||
# #
|
||||
# return [
|
||||
# e
|
||||
# for e in self.get_evaluations_etats()
|
||||
# if e["moduleimpl_id"] == moduleimpl_id
|
||||
# ]
|
||||
|
||||
def get_moduleimpls_attente(self):
|
||||
"""Liste des modimpls du semestre ayant des notes en attente"""
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
10
app/email.py
10
app/email.py
|
@ -1,10 +1,11 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
import datetime
|
||||
from threading import Thread
|
||||
|
||||
from flask import current_app, g
|
||||
|
@ -83,9 +84,12 @@ Adresses d'origine:
|
|||
\n\n"""
|
||||
+ msg.body
|
||||
)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S") + ".{:03d}".format(
|
||||
now.microsecond // 1000
|
||||
)
|
||||
current_app.logger.info(
|
||||
f"""email sent to{
|
||||
f"""[{formatted_time}] email sent to{
|
||||
' (mode test)' if email_test_mode_address else ''
|
||||
}: {msg.recipients}
|
||||
from sender {msg.sender}
|
||||
|
|
|
@ -59,3 +59,4 @@ def check_taxe_now(taxes):
|
|||
|
||||
|
||||
from app.entreprises import routes
|
||||
from app.entreprises.activate import activate_module
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Activation du module entreprises
|
||||
|
||||
L'affichage du module est contrôlé par la config ScoDocConfig.enable_entreprises
|
||||
|
||||
Au moment de l'activation, il est en général utile de proposer de configurer les
|
||||
permissions de rôles standards: AdminEntreprise UtilisateurEntreprise ObservateurEntreprise
|
||||
|
||||
Voir associations dans sco_roles_default
|
||||
|
||||
"""
|
||||
from app.auth.models import Role
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.scodoc.sco_roles_default import SCO_ROLES_ENTREPRISES_DEFAULT
|
||||
|
||||
|
||||
def activate_module(
|
||||
enable: bool = True, set_default_roles_permission: bool = False
|
||||
) -> bool:
|
||||
"""Active le module et en option donne les permissions aux rôles standards.
|
||||
True si l'état d'activation a changé.
|
||||
"""
|
||||
change = ScoDocSiteConfig.enable_entreprises(enable)
|
||||
if enable and set_default_roles_permission:
|
||||
Role.reset_roles_permissions(SCO_ROLES_ENTREPRISES_DEFAULT)
|
||||
return change
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -32,6 +32,7 @@ Formulaire ajout d'un justificatif sur un étudiant
|
|||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import MultipleFileField
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
SelectField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
|
@ -101,7 +102,7 @@ class AjoutAssiOrJustForm(FlaskForm):
|
|||
)
|
||||
|
||||
entry_date = StringField(
|
||||
"Date de dépot ou saisie",
|
||||
"Date de dépôt ou saisie",
|
||||
validators=[validators.Length(max=10)],
|
||||
render_kw={
|
||||
"class": "datepicker",
|
||||
|
@ -109,12 +110,23 @@ class AjoutAssiOrJustForm(FlaskForm):
|
|||
"id": "entry_date",
|
||||
},
|
||||
)
|
||||
entry_time = StringField(
|
||||
"Heure dépôt",
|
||||
default="",
|
||||
validators=[validators.Length(max=5)],
|
||||
render_kw={
|
||||
"class": "timepicker",
|
||||
"size": 5,
|
||||
"id": "assi_heure_fin",
|
||||
},
|
||||
)
|
||||
submit = SubmitField("Enregistrer")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
|
||||
"Formulaire de saisie d'une assiduité pour un étudiant"
|
||||
|
||||
description = TextAreaField(
|
||||
"Description",
|
||||
render_kw={
|
||||
|
@ -136,10 +148,12 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
|
|||
"Module",
|
||||
choices={}, # will be populated dynamically
|
||||
)
|
||||
est_just = BooleanField("Justifiée")
|
||||
|
||||
|
||||
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
|
||||
"Formulaire de saisie d'un justificatif pour un étudiant"
|
||||
|
||||
raison = TextAreaField(
|
||||
"Raison",
|
||||
render_kw={
|
||||
|
@ -161,3 +175,36 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
|
|||
validators=[DataRequired(message="This field is required.")],
|
||||
)
|
||||
fichiers = MultipleFileField(label="Ajouter des fichiers")
|
||||
|
||||
|
||||
class ChoixDateForm(FlaskForm):
|
||||
"""
|
||||
Formulaire de choix de date
|
||||
(utilisé par la page de choix de date
|
||||
si la date courante n'est pas dans le semestre)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"Init form, adding a filed for our error messages"
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ok = True
|
||||
self.error_messages: list[str] = [] # used to report our errors
|
||||
|
||||
def set_error(self, err_msg, field=None):
|
||||
"Set error message both in form and field"
|
||||
self.ok = False
|
||||
self.error_messages.append(err_msg)
|
||||
if field:
|
||||
field.errors.append(err_msg)
|
||||
|
||||
date = StringField(
|
||||
"Date",
|
||||
validators=[validators.Length(max=10)],
|
||||
render_kw={
|
||||
"class": "datepicker",
|
||||
"size": 10,
|
||||
"id": "date",
|
||||
},
|
||||
)
|
||||
submit = SubmitField("Enregistrer")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
|
|
@ -17,7 +17,7 @@ def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
|
|||
pass
|
||||
|
||||
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
|
||||
# Initialise un champs de saisie par parcours
|
||||
# Initialise un champ de saisie par parcours
|
||||
for parcour in parcours:
|
||||
ects = ue.get_ects(parcour, only_parcours=True)
|
||||
setattr(
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -43,6 +43,7 @@ def gen_formsemestre_change_formation_form(
|
|||
formations: list[Formation],
|
||||
) -> FormSemestreChangeFormationForm:
|
||||
"Create our dynamical form"
|
||||
|
||||
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
|
||||
class F(FormSemestreChangeFormationForm):
|
||||
pass
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
Formulaire activation module entreprises
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms.fields.simple import BooleanField, SubmitField
|
||||
|
||||
from app.models import ScoDocSiteConfig
|
||||
|
||||
|
||||
class ActivateEntreprisesForm(FlaskForm):
|
||||
"Formulaire activation module entreprises"
|
||||
set_default_roles_permission = BooleanField(
|
||||
"(re)mettre les rôles 'Entreprise' à leurs valeurs par défaut"
|
||||
)
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -34,52 +34,11 @@ import re
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import DecimalField, SubmitField, ValidationError
|
||||
from wtforms.fields.simple import StringField
|
||||
from wtforms.validators import Optional
|
||||
from wtforms.validators import Optional, Length
|
||||
|
||||
from wtforms.widgets import TimeInput
|
||||
|
||||
|
||||
class TimeField(StringField):
|
||||
"""HTML5 time input.
|
||||
tiré de : https://gist.github.com/tachyondecay/6016d32f65a996d0d94f
|
||||
"""
|
||||
|
||||
widget = TimeInput()
|
||||
|
||||
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
|
||||
super(TimeField, self).__init__(label, validators, **kwargs)
|
||||
self.fmt = fmt
|
||||
self.data = None
|
||||
|
||||
def _value(self):
|
||||
if self.raw_data:
|
||||
return " ".join(self.raw_data)
|
||||
if self.data and isinstance(self.data, str):
|
||||
self.data = datetime.time(*map(int, self.data.split(":")))
|
||||
return self.data and self.data.strftime(self.fmt) or ""
|
||||
|
||||
def process_formdata(self, valuelist):
|
||||
if valuelist:
|
||||
time_str = " ".join(valuelist)
|
||||
try:
|
||||
components = time_str.split(":")
|
||||
hour = 0
|
||||
minutes = 0
|
||||
seconds = 0
|
||||
if len(components) in range(2, 4):
|
||||
hour = int(components[0])
|
||||
minutes = int(components[1])
|
||||
|
||||
if len(components) == 3:
|
||||
seconds = int(components[2])
|
||||
else:
|
||||
raise ValueError
|
||||
self.data = datetime.time(hour, minutes, seconds)
|
||||
except ValueError as exc:
|
||||
self.data = None
|
||||
raise ValueError(self.gettext("Not a valid time string")) from exc
|
||||
|
||||
|
||||
def check_tick_time(form, field):
|
||||
"""Le tick_time doit être entre 0 et 60 minutes"""
|
||||
if field.data < 1 or field.data > 59:
|
||||
|
@ -118,12 +77,38 @@ def check_ics_regexp(form, field):
|
|||
|
||||
class ConfigAssiduitesForm(FlaskForm):
|
||||
"Formulaire paramétrage Module Assiduité"
|
||||
assi_morning_time = StringField(
|
||||
"Début de la journée",
|
||||
default="",
|
||||
validators=[Length(max=5)],
|
||||
render_kw={
|
||||
"class": "timepicker",
|
||||
"size": 5,
|
||||
"id": "assi_morning_time",
|
||||
},
|
||||
)
|
||||
assi_lunch_time = StringField(
|
||||
"Heure de midi (date pivot entre matin et après-midi)",
|
||||
default="",
|
||||
validators=[Length(max=5)],
|
||||
render_kw={
|
||||
"class": "timepicker",
|
||||
"size": 5,
|
||||
"id": "assi_lunch_time",
|
||||
},
|
||||
)
|
||||
assi_afternoon_time = StringField(
|
||||
"Fin de la journée",
|
||||
validators=[Length(max=5)],
|
||||
default="",
|
||||
render_kw={
|
||||
"class": "timepicker",
|
||||
"size": 5,
|
||||
"id": "assi_afternoon_time",
|
||||
},
|
||||
)
|
||||
|
||||
morning_time = TimeField("Début de la journée")
|
||||
lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)")
|
||||
afternoon_time = TimeField("Fin de la journée")
|
||||
|
||||
tick_time = DecimalField(
|
||||
assi_tick_time = DecimalField(
|
||||
"Granularité de la timeline (temps en minutes)",
|
||||
places=0,
|
||||
validators=[check_tick_time],
|
||||
|
@ -137,9 +122,19 @@ class ConfigAssiduitesForm(FlaskForm):
|
|||
Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""",
|
||||
validators=[Optional(), check_ics_path],
|
||||
)
|
||||
edt_ics_user_path = StringField(
|
||||
label="Chemin vers les ics des utilisateurs (enseignants)",
|
||||
description="""Optionnel. Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi
|
||||
du temps d'un enseignant. La balise <tt>{edt_id}</tt> sera remplacée par l'edt_id du
|
||||
de l'utilisateur.
|
||||
Dans certains cas (XXX), ScoDoc peut générer ces fichiers et les écrira suivant
|
||||
ce chemin (avec edt_id).
|
||||
""",
|
||||
validators=[Optional(), check_ics_path],
|
||||
)
|
||||
|
||||
edt_ics_title_field = StringField(
|
||||
label="Champs contenant le titre",
|
||||
label="Champ contenant le titre",
|
||||
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
|
||||
validators=[Optional(), check_ics_field],
|
||||
)
|
||||
|
@ -152,7 +147,7 @@ class ConfigAssiduitesForm(FlaskForm):
|
|||
validators=[Optional(), check_ics_regexp],
|
||||
)
|
||||
edt_ics_group_field = StringField(
|
||||
label="Champs contenant le groupe",
|
||||
label="Champ contenant le groupe",
|
||||
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
|
||||
validators=[Optional(), check_ics_field],
|
||||
)
|
||||
|
@ -165,7 +160,7 @@ class ConfigAssiduitesForm(FlaskForm):
|
|||
validators=[Optional(), check_ics_regexp],
|
||||
)
|
||||
edt_ics_mod_field = StringField(
|
||||
label="Champs contenant le module",
|
||||
label="Champ contenant le module",
|
||||
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
|
||||
validators=[Optional(), check_ics_field],
|
||||
)
|
||||
|
@ -177,6 +172,19 @@ class ConfigAssiduitesForm(FlaskForm):
|
|||
""",
|
||||
validators=[Optional(), check_ics_regexp],
|
||||
)
|
||||
|
||||
edt_ics_uid_field = StringField(
|
||||
label="Champ contenant les enseignants",
|
||||
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
|
||||
validators=[Optional(), check_ics_field],
|
||||
)
|
||||
edt_ics_uid_regexp = StringField(
|
||||
label="Extraction des enseignants",
|
||||
description=r"""expression régulière python permettant d'extraire les
|
||||
identifiants des enseignants associés à l'évènement.
|
||||
(contrairement aux autres champs, il peut y avoir plusieurs enseignants par évènement.)
|
||||
Exemple: <tt>[0-9]+</tt>
|
||||
""",
|
||||
validators=[Optional(), check_ics_regexp],
|
||||
)
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -82,7 +82,7 @@ class ConfigCASForm(FlaskForm):
|
|||
|
||||
cas_attribute_id = StringField(
|
||||
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
|
||||
description="""Le champs CAS qui sera considéré comme l'id unique des
|
||||
description="""Le champ CAS qui sera considéré comme l'id unique des
|
||||
comptes utilisateurs.""",
|
||||
)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -48,13 +48,15 @@ class BonusConfigurationForm(FlaskForm):
|
|||
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
|
||||
],
|
||||
)
|
||||
submit_bonus = SubmitField("Valider")
|
||||
submit_bonus = SubmitField("Enregistrer ce bonus")
|
||||
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
class ScoDocConfigurationForm(FlaskForm):
|
||||
"Panneau de configuration avancée"
|
||||
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
|
||||
disable_passerelle = BooleanField( # disable car par défaut activée
|
||||
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
|
||||
)
|
||||
month_debut_annee_scolaire = SelectField(
|
||||
label="Mois de début des années scolaires",
|
||||
description="""Date pivot. En France métropolitaine, août.
|
||||
|
@ -77,10 +79,13 @@ class ScoDocConfigurationForm(FlaskForm):
|
|||
Attention: si ce champ peut aussi être défini dans chaque département.""",
|
||||
validators=[Optional(), Email()],
|
||||
)
|
||||
user_require_email_institutionnel = BooleanField(
|
||||
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur"
|
||||
)
|
||||
disable_bul_pdf = BooleanField(
|
||||
"interdire les exports des bulletins en PDF (déconseillé)"
|
||||
)
|
||||
submit_scodoc = SubmitField("Valider")
|
||||
submit_scodoc = SubmitField("Enregistrer ces paramètres")
|
||||
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
|
@ -95,10 +100,12 @@ def configuration():
|
|||
form_scodoc = ScoDocConfigurationForm(
|
||||
data={
|
||||
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
|
||||
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
|
||||
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
|
||||
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
|
||||
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
|
||||
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
|
||||
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
|
||||
}
|
||||
)
|
||||
if request.method == "POST" and (
|
||||
|
@ -119,12 +126,12 @@ def configuration():
|
|||
flash("Fonction bonus inchangée.")
|
||||
return redirect(url_for("scodoc.index"))
|
||||
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
|
||||
if ScoDocSiteConfig.enable_entreprises(
|
||||
enabled=form_scodoc.data["enable_entreprises"]
|
||||
if ScoDocSiteConfig.disable_passerelle(
|
||||
disabled=form_scodoc.data["disable_passerelle"]
|
||||
):
|
||||
flash(
|
||||
"Module entreprise "
|
||||
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
|
||||
"Fonction passerelle "
|
||||
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
|
||||
)
|
||||
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
|
||||
int(form_scodoc.data["month_debut_annee_scolaire"])
|
||||
|
@ -151,10 +158,23 @@ def configuration():
|
|||
"Exports PDF "
|
||||
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés")
|
||||
)
|
||||
if ScoDocSiteConfig.set(
|
||||
"user_require_email_institutionnel",
|
||||
"on" if form_scodoc.data["user_require_email_institutionnel"] else "",
|
||||
):
|
||||
flash(
|
||||
(
|
||||
"impose"
|
||||
if form_scodoc.data["user_require_email_institutionnel"]
|
||||
else "n'impose pas"
|
||||
)
|
||||
+ " la saisie du mail institutionnel des utilisateurs"
|
||||
)
|
||||
return redirect(url_for("scodoc.index"))
|
||||
|
||||
return render_template(
|
||||
"configuration.j2",
|
||||
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
|
||||
form_bonus=form_bonus,
|
||||
form_scodoc=form_scodoc,
|
||||
scu=scu,
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Formulaire configuration RGPD
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import SubmitField
|
||||
from wtforms.fields.simple import TextAreaField
|
||||
|
||||
|
||||
class ConfigRGPDForm(FlaskForm):
|
||||
"Formulaire paramétrage RGPD"
|
||||
rgpd_coordonnees_dpo = TextAreaField(
|
||||
label="Optionnel: coordonnées du DPO",
|
||||
description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre
|
||||
la conformité au règlement européen sur la protection des données (RGPD) au sein de l’organisme.
|
||||
Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc.
|
||||
""",
|
||||
render_kw={"rows": 5, "cols": 72},
|
||||
)
|
||||
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
##############################################################################
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Formulaire options génération table poursuite études (PE)
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, HiddenField, SubmitField
|
||||
|
||||
|
||||
class ParametrageClasseurPE(FlaskForm):
|
||||
"Formulaire paramétrage génération classeur PE"
|
||||
# cohorte_restreinte = BooleanField(
|
||||
# "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)"
|
||||
# )
|
||||
moyennes_tags = BooleanField(
|
||||
"Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
moyennes_ue_res_sae = BooleanField(
|
||||
"Générer les moyennes des ressources et des SAEs",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
moyennes_ues_rcues = BooleanField(
|
||||
"Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
|
||||
min_max_moy = BooleanField("Afficher les colonnes min/max/moy")
|
||||
|
||||
# synthese_individuelle_etud = BooleanField(
|
||||
# "Générer (suppose les RCUES)"
|
||||
# )
|
||||
publipostage = BooleanField(
|
||||
"Nomme les moyennes pour publipostage",
|
||||
# default=False,
|
||||
# render_kw={"checked": ""},
|
||||
)
|
||||
submit = SubmitField("Générer les classeurs poursuites d'études")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -52,7 +52,7 @@ class ScoDocModel(db.Model):
|
|||
def create_from_dict(cls, data: dict) -> "ScoDocModel":
|
||||
"""Create a new instance of the model with attributes given in dict.
|
||||
The instance is added to the session (but not flushed nor committed).
|
||||
Use only relevant arributes for the given model and ignore others.
|
||||
Use only relevant attributes for the given model and ignore others.
|
||||
"""
|
||||
if data:
|
||||
args = cls.convert_dict_fields(cls.filter_model_attributes(data))
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
"""Gestion de l'assiduité (assiduités + justificatifs)
|
||||
"""
|
||||
"""Gestion de l'assiduité (assiduités + justificatifs)"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
|
@ -20,11 +21,13 @@ from app.scodoc import sco_abs_notification
|
|||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_utils import (
|
||||
EtatAssiduite,
|
||||
EtatJustificatif,
|
||||
localize_datetime,
|
||||
is_assiduites_module_forced,
|
||||
NonWorkDays,
|
||||
)
|
||||
|
||||
|
||||
|
@ -86,8 +89,12 @@ class Assiduite(ScoDocModel):
|
|||
lazy="select",
|
||||
)
|
||||
|
||||
def to_dict(self, format_api=True) -> dict:
|
||||
"""Retourne la représentation json de l'assiduité"""
|
||||
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
|
||||
# pylint: disable-next=unused-argument
|
||||
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
|
||||
"""Retourne la représentation json de l'assiduité
|
||||
restrict n'est pas utilisé ici.
|
||||
"""
|
||||
etat = self.etat
|
||||
user: User | None = None
|
||||
if format_api:
|
||||
|
@ -107,9 +114,9 @@ class Assiduite(ScoDocModel):
|
|||
"entry_date": self.entry_date,
|
||||
"user_id": None if user is None else user.id, # l'uid
|
||||
"user_name": None if user is None else user.user_name, # le login
|
||||
"user_nom_complet": None
|
||||
if user is None
|
||||
else user.get_nomcomplet(), # "Marie Dupont"
|
||||
"user_nom_complet": (
|
||||
None if user is None else user.get_nomcomplet()
|
||||
), # "Marie Dupont"
|
||||
"est_just": self.est_just,
|
||||
"external_data": self.external_data,
|
||||
}
|
||||
|
@ -154,11 +161,39 @@ class Assiduite(ScoDocModel):
|
|||
)
|
||||
if date_fin.tzinfo is None:
|
||||
log(f"Warning: create_assiduite: date_fin without timezone ({date_fin})")
|
||||
|
||||
# Vérification jours non travaillés
|
||||
# -> vérifie si la date de début ou la date de fin est sur un jour non travaillé
|
||||
# On récupère les formsemestres des dates de début et de fin
|
||||
formsemestre_date_debut: FormSemestre = get_formsemestre_from_data(
|
||||
{
|
||||
"etudid": etud.id,
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_debut,
|
||||
}
|
||||
)
|
||||
formsemestre_date_fin: FormSemestre = get_formsemestre_from_data(
|
||||
{
|
||||
"etudid": etud.id,
|
||||
"date_debut": date_fin,
|
||||
"date_fin": date_fin,
|
||||
}
|
||||
)
|
||||
if date_debut.weekday() in NonWorkDays.get_all_non_work_days(
|
||||
formsemestre_id=formsemestre_date_debut
|
||||
):
|
||||
raise ScoValueError("La date de début n'est pas un jour travaillé")
|
||||
if date_fin.weekday() in NonWorkDays.get_all_non_work_days(
|
||||
formsemestre_id=formsemestre_date_fin
|
||||
):
|
||||
raise ScoValueError("La date de fin n'est pas un jour travaillé")
|
||||
|
||||
# Vérification de non duplication des périodes
|
||||
assiduites: Query = etud.assiduites
|
||||
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
|
||||
log(
|
||||
f"create_assiduite: period_conflicting etudid={etud.id} date_debut={date_debut} date_fin={date_fin}"
|
||||
f"""create_assiduite: period_conflicting etudid={etud.id} date_debut={
|
||||
date_debut} date_fin={date_fin}"""
|
||||
)
|
||||
raise ScoValueError(
|
||||
"Duplication: la période rentre en conflit avec une plage enregistrée"
|
||||
|
@ -220,53 +255,63 @@ class Assiduite(ScoDocModel):
|
|||
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
|
||||
return nouv_assiduite
|
||||
|
||||
def set_moduleimpl(self, moduleimpl_id: int | str) -> bool:
|
||||
"""TODO"""
|
||||
# je ne comprend pas cette fonction WIP
|
||||
# moduleimpl_id peut être == "autre", ce qui plante
|
||||
# ci-dessous un fix temporaire en attendant explication de @iziram
|
||||
if moduleimpl_id is None:
|
||||
raise ScoValueError("invalid moduleimpl_id")
|
||||
try:
|
||||
moduleimpl_id_int = int(moduleimpl_id)
|
||||
except ValueError as exc:
|
||||
raise ScoValueError("invalid moduleimpl_id") from exc
|
||||
# /fix
|
||||
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id_int)
|
||||
if moduleimpl is not None:
|
||||
# Vérification de l'inscription de l'étudiant
|
||||
if moduleimpl.est_inscrit(self.etudiant):
|
||||
self.moduleimpl_id = moduleimpl.id
|
||||
else:
|
||||
raise ScoValueError("L'étudiant n'est pas inscrit au module")
|
||||
elif isinstance(moduleimpl_id, str):
|
||||
def set_moduleimpl(self, moduleimpl_id: int | str):
|
||||
"""Mise à jour du moduleimpl_id
|
||||
Les valeurs du champ "moduleimpl_id" possibles sont :
|
||||
- <int> (un id classique)
|
||||
- <str> ("autre" ou "<id>")
|
||||
- "" (pas de moduleimpl_id)
|
||||
Si la valeur est "autre" il faut:
|
||||
- mettre à None assiduité.moduleimpl_id
|
||||
- mettre à jour assiduite.external_data["module"] = "autre"
|
||||
En fonction de la configuration du semestre (option force_module) la valeur "" peut-être
|
||||
considérée comme invalide.
|
||||
- Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité
|
||||
"""
|
||||
moduleimpl: ModuleImpl = None
|
||||
if moduleimpl_id == "autre":
|
||||
# Configuration de external_data pour Module Autre
|
||||
# Si self.external_data None alors on créé un dictionnaire {"module": "autre"}
|
||||
# Sinon on met à jour external_data["module"] à "autre"
|
||||
|
||||
if self.external_data is None:
|
||||
self.external_data = {"module": moduleimpl_id}
|
||||
self.external_data = {"module": "autre"}
|
||||
else:
|
||||
self.external_data["module"] = moduleimpl_id
|
||||
self.external_data["module"] = "autre"
|
||||
|
||||
# Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None
|
||||
self.moduleimpl_id = None
|
||||
|
||||
# Ici pas de vérification du force module car on l'a mis dans "external_data"
|
||||
return
|
||||
|
||||
if moduleimpl_id != "":
|
||||
try:
|
||||
moduleimpl_id = int(moduleimpl_id)
|
||||
except ValueError as exc:
|
||||
raise ScoValueError("Module non reconnu") from exc
|
||||
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
|
||||
|
||||
# ici moduleimpl est None si non spécifié
|
||||
|
||||
# Vérification ModuleImpl not None (raise ScoValueError)
|
||||
if moduleimpl is None:
|
||||
self._check_force_module()
|
||||
# Ici uniquement si on est autorisé à ne pas avoir de module
|
||||
self.moduleimpl_id = None
|
||||
return
|
||||
|
||||
# Vérification Inscription ModuleImpl (raise ScoValueError)
|
||||
if moduleimpl.est_inscrit(self.etudiant):
|
||||
self.moduleimpl_id = moduleimpl.id
|
||||
else:
|
||||
# Vérification si module forcé
|
||||
formsemestre: FormSemestre = get_formsemestre_from_data(
|
||||
{
|
||||
"etudid": self.etudid,
|
||||
"date_debut": self.date_debut,
|
||||
"date_fin": self.date_fin,
|
||||
}
|
||||
)
|
||||
force: bool
|
||||
|
||||
if formsemestre:
|
||||
force = is_assiduites_module_forced(formsemestre_id=formsemestre.id)
|
||||
else:
|
||||
force = is_assiduites_module_forced(dept_id=self.etudiant.dept_id)
|
||||
|
||||
if force:
|
||||
raise ScoValueError("Module non renseigné")
|
||||
return True
|
||||
raise ScoValueError("L'étudiant n'est pas inscrit au module")
|
||||
|
||||
def supprime(self):
|
||||
"Supprime l'assiduité. Log et commit."
|
||||
|
||||
# Obligatoire car import circulaire sinon
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from app.scodoc import sco_assiduites as scass
|
||||
|
||||
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
|
||||
|
@ -292,13 +337,19 @@ class Assiduite(ScoDocModel):
|
|||
"""
|
||||
return get_formsemestre_from_data(self.to_dict())
|
||||
|
||||
def get_module(self, traduire: bool = False) -> int | str:
|
||||
"TODO"
|
||||
def get_module(self, traduire: bool = False) -> Module | str:
|
||||
"""
|
||||
Retourne le module associé à l'assiduité
|
||||
Si traduire est vrai, retourne le titre du module précédé du code
|
||||
Sinon rentourne l'objet Module ou None
|
||||
"""
|
||||
|
||||
if self.moduleimpl_id is not None:
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
mod: Module = Module.query.get(modimpl.module_id)
|
||||
if traduire:
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
mod: Module = Module.query.get(modimpl.module_id)
|
||||
return f"{mod.code} {mod.titre}"
|
||||
return mod
|
||||
|
||||
elif self.external_data is not None and "module" in self.external_data:
|
||||
return (
|
||||
|
@ -309,6 +360,41 @@ class Assiduite(ScoDocModel):
|
|||
|
||||
return "Non spécifié" if traduire else None
|
||||
|
||||
def get_saisie(self) -> str:
|
||||
"""
|
||||
retourne le texte "saisie le <date> par <User>"
|
||||
"""
|
||||
|
||||
date: str = self.entry_date.strftime(scu.DATEATIME_FMT)
|
||||
utilisateur: str = ""
|
||||
if self.user is not None:
|
||||
self.user: User
|
||||
utilisateur = f"par {self.user.get_prenomnom()}"
|
||||
|
||||
return f"saisie le {date} {utilisateur}"
|
||||
|
||||
def _check_force_module(self):
|
||||
"""Vérification si module forcé:
|
||||
Si le module est requis, raise ScoValueError
|
||||
sinon ne fait rien.
|
||||
"""
|
||||
# cherche le formsemestre affecté pour utiliser ses préférences
|
||||
formsemestre: FormSemestre = get_formsemestre_from_data(
|
||||
{
|
||||
"etudid": self.etudid,
|
||||
"date_debut": self.date_debut,
|
||||
"date_fin": self.date_fin,
|
||||
}
|
||||
)
|
||||
formsemestre_id = formsemestre.id if formsemestre else None
|
||||
# si pas de formsemestre, utilisera les prefs globales du département
|
||||
dept_id = self.etudiant.dept_id
|
||||
force = is_assiduites_module_forced(
|
||||
formsemestre_id=formsemestre_id, dept_id=dept_id
|
||||
)
|
||||
if force:
|
||||
raise ScoValueError("Module non renseigné")
|
||||
|
||||
|
||||
class Justificatif(ScoDocModel):
|
||||
"""
|
||||
|
@ -343,7 +429,7 @@ class Justificatif(ScoDocModel):
|
|||
|
||||
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
"date de création de l'élément: date de saisie"
|
||||
# pourrait devenir date de dépot au secrétariat, si différente
|
||||
# pourrait devenir date de dépôt au secrétariat, si différente
|
||||
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
|
@ -362,6 +448,14 @@ class Justificatif(ScoDocModel):
|
|||
etudiant = db.relationship(
|
||||
"Identite", back_populates="justificatifs", lazy="joined"
|
||||
)
|
||||
# En revanche, user est rarement accédé:
|
||||
user = db.relationship(
|
||||
"User",
|
||||
backref=db.backref(
|
||||
"justificatifs", lazy="select", order_by="Justificatif.entry_date"
|
||||
),
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
external_data = db.Column(db.JSON, nullable=True)
|
||||
|
||||
|
@ -373,20 +467,16 @@ class Justificatif(ScoDocModel):
|
|||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
return query.first_or_404()
|
||||
|
||||
def to_dict(self, format_api: bool = False) -> dict:
|
||||
"""transformation de l'objet en dictionnaire sérialisable"""
|
||||
def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict:
|
||||
"""L'objet en dictionnaire sérialisable.
|
||||
Si restrict, ne donne par la raison et les fichiers et external_data
|
||||
"""
|
||||
|
||||
etat = self.etat
|
||||
username = self.user_id
|
||||
user: User = self.user if self.user_id is not None else None
|
||||
|
||||
if format_api:
|
||||
etat = EtatJustificatif.inverse().get(self.etat).name
|
||||
if self.user_id is not None:
|
||||
user: User = db.session.get(User, self.user_id)
|
||||
if user is None:
|
||||
username = "Non renseigné"
|
||||
else:
|
||||
username = user.get_prenomnom()
|
||||
|
||||
data = {
|
||||
"justif_id": self.justif_id,
|
||||
|
@ -395,11 +485,13 @@ class Justificatif(ScoDocModel):
|
|||
"date_debut": self.date_debut,
|
||||
"date_fin": self.date_fin,
|
||||
"etat": etat,
|
||||
"raison": self.raison,
|
||||
"fichier": self.fichier,
|
||||
"raison": None if restrict else self.raison,
|
||||
"fichier": None if restrict else self.fichier,
|
||||
"entry_date": self.entry_date,
|
||||
"user_id": username,
|
||||
"external_data": self.external_data,
|
||||
"user_id": None if user is None else user.id, # l'uid
|
||||
"user_name": None if user is None else user.user_name, # le login
|
||||
"user_nom_complet": None if user is None else user.get_nomcomplet(),
|
||||
"external_data": None if restrict else self.external_data,
|
||||
}
|
||||
return data
|
||||
|
||||
|
@ -434,6 +526,8 @@ class Justificatif(ScoDocModel):
|
|||
def create_justificatif(
|
||||
cls,
|
||||
etudiant: Identite,
|
||||
# On a besoin des arguments mais on utilise "locals" pour les récupérer
|
||||
# pylint: disable=unused-argument
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatJustificatif,
|
||||
|
@ -457,8 +551,10 @@ class Justificatif(ScoDocModel):
|
|||
|
||||
def supprime(self):
|
||||
"Supprime le justificatif. Log et commit."
|
||||
|
||||
# Obligatoire car import circulaire sinon
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from app.scodoc import sco_assiduites as scass
|
||||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
|
||||
# Récupération de l'archive du justificatif
|
||||
archive_name: str = self.fichier
|
||||
|
@ -485,11 +581,7 @@ class Justificatif(ScoDocModel):
|
|||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
compute_assiduites_justified(
|
||||
self.etudid,
|
||||
Justificatif.query.filter_by(etudid=self.etudid).all(),
|
||||
True,
|
||||
)
|
||||
self.dejustifier_assiduites()
|
||||
|
||||
def get_fichiers(self) -> tuple[list[str], int]:
|
||||
"""Renvoie la liste des noms de fichiers justicatifs
|
||||
|
@ -511,6 +603,82 @@ class Justificatif(ScoDocModel):
|
|||
accessible_filenames.append(filename[0])
|
||||
return accessible_filenames, len(filenames)
|
||||
|
||||
def justifier_assiduites(
|
||||
self,
|
||||
) -> list[int]:
|
||||
"""Justifie les assiduités sur la période de validité du justificatif"""
|
||||
log(f"justifier_assiduites: {self}")
|
||||
assiduites_justifiees: list[int] = []
|
||||
if self.etat != EtatJustificatif.VALIDE:
|
||||
return []
|
||||
# On récupère les assiduités de l'étudiant sur la période donnée
|
||||
assiduites: Query = self.etudiant.assiduites.filter(
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
# Pour chaque assiduité, on la justifie
|
||||
for assi in assiduites:
|
||||
assi.est_just = True
|
||||
assiduites_justifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return assiduites_justifiees
|
||||
|
||||
def dejustifier_assiduites(self) -> list[int]:
|
||||
"""
|
||||
Déjustifie les assiduités sur la période du justificatif
|
||||
"""
|
||||
assiduites_dejustifiees: list[int] = []
|
||||
|
||||
# On récupère les assiduités de l'étudiant sur la période donnée
|
||||
assiduites: Query = self.etudiant.assiduites.filter(
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
assi: Assiduite
|
||||
for assi in assiduites:
|
||||
# On récupère les justificatifs qui justifient l'assiduité `assi`
|
||||
assi_justifs: list[int] = get_justifs_from_date(
|
||||
self.etudiant.etudid,
|
||||
assi.date_debut,
|
||||
assi.date_fin,
|
||||
long=False,
|
||||
valid=True,
|
||||
)
|
||||
# Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité
|
||||
if len(assi_justifs) == 0 or (
|
||||
len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id
|
||||
):
|
||||
assi.est_just = False
|
||||
assiduites_dejustifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return assiduites_dejustifiees
|
||||
|
||||
def get_assiduites(self) -> Query:
|
||||
"""
|
||||
get_assiduites Récupère les assiduités qui sont concernées par le justificatif
|
||||
(Concernée ≠ Justifiée, mais qui sont sur la même période)
|
||||
Ne prends pas en compte les Présences
|
||||
Returns:
|
||||
Query: Les assiduités concernées
|
||||
"""
|
||||
|
||||
assiduites_query = Assiduite.query.filter(
|
||||
Assiduite.etudid == self.etudid,
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
|
||||
return assiduites_query
|
||||
|
||||
|
||||
def is_period_conflicting(
|
||||
date_debut: datetime,
|
||||
|
@ -534,66 +702,6 @@ def is_period_conflicting(
|
|||
return count > 0
|
||||
|
||||
|
||||
def compute_assiduites_justified(
|
||||
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
|
||||
) -> list[int]:
|
||||
"""
|
||||
Args:
|
||||
etudid (int): l'identifiant de l'étudiant
|
||||
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
|
||||
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
|
||||
|
||||
Returns:
|
||||
list[int]: la liste des assiduités qui ont été justifiées.
|
||||
"""
|
||||
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
|
||||
if justificatifs is None:
|
||||
justificatifs: list[Justificatif] = Justificatif.query.filter_by(
|
||||
etudid=etudid
|
||||
).all()
|
||||
|
||||
# On ne prend que les justificatifs valides
|
||||
justificatifs = [j for j in justificatifs if j.etat == EtatJustificatif.VALIDE]
|
||||
|
||||
# On récupère les assiduités de l'étudiant
|
||||
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
|
||||
|
||||
assiduites_justifiees: list[int] = []
|
||||
|
||||
for assi in assiduites:
|
||||
# On ne justifie pas les Présences
|
||||
if assi.etat == EtatAssiduite.PRESENT:
|
||||
continue
|
||||
|
||||
# On récupère les justificatifs qui justifient l'assiduité `assi`
|
||||
assi_justificatifs = Justificatif.query.filter(
|
||||
Justificatif.etudid == assi.etudid,
|
||||
Justificatif.date_debut <= assi.date_debut,
|
||||
Justificatif.date_fin >= assi.date_fin,
|
||||
Justificatif.etat == EtatJustificatif.VALIDE,
|
||||
).all()
|
||||
|
||||
# Si au moins un justificatif possède une période qui couvre l'assiduité
|
||||
if any(
|
||||
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
|
||||
for j in justificatifs + assi_justificatifs
|
||||
):
|
||||
# On justifie l'assiduité
|
||||
# On ajoute l'id de l'assiduité à la liste des assiduités justifiées
|
||||
assi.est_just = True
|
||||
assiduites_justifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
elif reset:
|
||||
# Si le paramètre reset est Vrai alors les assiduités non justifiées
|
||||
# sont remise en "non justifiée"
|
||||
assi.est_just = False
|
||||
db.session.add(assi)
|
||||
# On valide la session
|
||||
db.session.commit()
|
||||
# On renvoie la liste des assiduite_id des assiduités justifiées
|
||||
return assiduites_justifiees
|
||||
|
||||
|
||||
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
|
||||
"""
|
||||
get_assiduites_justif Récupération des justificatifs d'une assiduité
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
|
||||
|
@ -8,16 +8,19 @@
|
|||
from datetime import datetime
|
||||
import functools
|
||||
from operator import attrgetter
|
||||
import yaml
|
||||
|
||||
from flask import g
|
||||
from flask_sqlalchemy.query import Query
|
||||
from sqlalchemy.orm import class_mapper
|
||||
import sqlalchemy
|
||||
|
||||
from app import db
|
||||
from app import db, log
|
||||
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
|
||||
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
|
||||
|
||||
|
||||
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
||||
|
@ -104,6 +107,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
def __repr__(self):
|
||||
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
|
||||
|
||||
def get_title(self) -> str:
|
||||
"Titre affichable"
|
||||
# utilise type_titre (B.U.T.), spécialité, version
|
||||
return f"{self.type_titre} {self.specialite} {self.get_version()}"
|
||||
|
||||
def get_version(self) -> str:
|
||||
"La version, normalement sous forme de date iso yyy-mm-dd"
|
||||
if not self.version_orebut:
|
||||
|
@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
"type_departement": self.type_departement,
|
||||
"type_titre": self.type_titre,
|
||||
"version_orebut": self.version_orebut,
|
||||
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
|
||||
if self.scodoc_date_loaded
|
||||
else "",
|
||||
"scodoc_date_loaded": (
|
||||
self.scodoc_date_loaded.isoformat() + "Z"
|
||||
if self.scodoc_date_loaded
|
||||
else ""
|
||||
),
|
||||
"scodoc_orig_filename": self.scodoc_orig_filename,
|
||||
"competences": {
|
||||
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
|
||||
|
@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
|
||||
return parcours_info
|
||||
|
||||
def equivalents(self) -> set["ApcReferentielCompetences"]:
|
||||
"""Ensemble des référentiels du même département
|
||||
qui peuvent être considérés comme "équivalents", au sens
|
||||
une formation de ce référentiel pourrait changer vers un équivalent,
|
||||
en ignorant les apprentissages critiques.
|
||||
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
|
||||
niveaux et parcours (voir map_to_other_referentiel).
|
||||
"""
|
||||
candidats = ApcReferentielCompetences.query.filter_by(
|
||||
dept_id=self.dept_id
|
||||
).filter(ApcReferentielCompetences.id != self.id)
|
||||
return {
|
||||
referentiel
|
||||
for referentiel in candidats
|
||||
if not isinstance(self.map_to_other_referentiel(referentiel), str)
|
||||
}
|
||||
|
||||
def map_to_other_referentiel(
|
||||
self, other: "ApcReferentielCompetences"
|
||||
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
|
||||
"""Build mapping between this referentiel and ref2.
|
||||
If successful, returns 3 dicts mapping self ids to other ids.
|
||||
Else return a string, error message.
|
||||
"""
|
||||
if self.type_structure != other.type_structure:
|
||||
return "type_structure mismatch"
|
||||
if self.type_departement != other.type_departement:
|
||||
return "type_departement mismatch"
|
||||
# Table d'équivalences entre refs:
|
||||
equiv = self._load_config_equivalences()
|
||||
# mêmes parcours ?
|
||||
eq_parcours = equiv.get("parcours", {})
|
||||
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
|
||||
parcours_by_code_2 = {
|
||||
eq_parcours.get(p.code, p.code): p for p in other.parcours
|
||||
}
|
||||
if parcours_by_code_1.keys() != parcours_by_code_2.keys():
|
||||
return "parcours mismatch"
|
||||
parcours_map = {
|
||||
parcours_by_code_1[eq_parcours.get(code, code)]
|
||||
.id: parcours_by_code_2[eq_parcours.get(code, code)]
|
||||
.id
|
||||
for code in parcours_by_code_1
|
||||
}
|
||||
# mêmes compétences ?
|
||||
competence_by_code_1 = {c.titre: c for c in self.competences}
|
||||
competence_by_code_2 = {c.titre: c for c in other.competences}
|
||||
if competence_by_code_1.keys() != competence_by_code_2.keys():
|
||||
return "competences mismatch"
|
||||
competences_map = {
|
||||
competence_by_code_1[titre].id: competence_by_code_2[titre].id
|
||||
for titre in competence_by_code_1
|
||||
}
|
||||
# mêmes niveaux (dans chaque compétence) ?
|
||||
niveaux_map = {}
|
||||
for titre in competence_by_code_1:
|
||||
c1 = competence_by_code_1[titre]
|
||||
c2 = competence_by_code_2[titre]
|
||||
niveau_by_attr_1 = {(n.annee, n.ordre, n.libelle): n for n in c1.niveaux}
|
||||
niveau_by_attr_2 = {(n.annee, n.ordre, n.libelle): n for n in c2.niveaux}
|
||||
if niveau_by_attr_1.keys() != niveau_by_attr_2.keys():
|
||||
return f"niveaux mismatch in comp. '{titre}'"
|
||||
niveaux_map.update(
|
||||
{
|
||||
niveau_by_attr_1[a].id: niveau_by_attr_2[a].id
|
||||
for a in niveau_by_attr_1
|
||||
}
|
||||
)
|
||||
return parcours_map, competences_map, niveaux_map
|
||||
|
||||
def _load_config_equivalences(self) -> dict:
|
||||
"""Load config file ressources/referentiels/equivalences.yaml
|
||||
used to define equivalences between distinct referentiels
|
||||
"""
|
||||
try:
|
||||
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f.read())
|
||||
except FileNotFoundError:
|
||||
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
|
||||
return {}
|
||||
except yaml.parser.ParserError as exc:
|
||||
raise ScoValueError(
|
||||
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
|
||||
) from exc
|
||||
return doc.get(self.specialite, {})
|
||||
|
||||
|
||||
class ApcCompetence(db.Model, XMLModel):
|
||||
"Compétence"
|
||||
|
@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
"libelle": self.libelle,
|
||||
"annee": self.annee,
|
||||
"ordre": self.ordre,
|
||||
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
|
||||
if with_app_critiques
|
||||
else {},
|
||||
"app_critiques": (
|
||||
{x.code: x.to_dict() for x in self.app_critiques}
|
||||
if with_app_critiques
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
def to_dict_bul(self):
|
||||
|
@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
return []
|
||||
|
||||
if competence is None:
|
||||
parcour_niveaux: list[
|
||||
ApcParcoursNiveauCompetence
|
||||
] = annee_parcour.niveaux_competences
|
||||
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
|
||||
annee_parcour.niveaux_competences
|
||||
)
|
||||
niveaux: list[ApcNiveau] = [
|
||||
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
|
||||
for pn in parcour_niveaux
|
||||
|
|
|
@ -9,6 +9,8 @@ from app.models.but_refcomp import ApcNiveau
|
|||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ApcValidationRCUE(db.Model):
|
||||
|
@ -62,24 +64,25 @@ class ApcValidationRCUE(db.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
|
||||
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
|
||||
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
|
||||
|
||||
def html(self) -> str:
|
||||
"description en HTML"
|
||||
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
|
||||
<b>{self.code}</b>
|
||||
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
|
||||
à {self.date.strftime("%Hh%M")}</em>"""
|
||||
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
|
||||
|
||||
def annee(self) -> str:
|
||||
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
|
||||
niveau = self.niveau()
|
||||
return niveau.annee if niveau else None
|
||||
|
||||
def niveau(self) -> ApcNiveau:
|
||||
def niveau(self) -> ApcNiveau | None:
|
||||
"""Le niveau de compétence associé à cet RCUE."""
|
||||
# Par convention, il est donné par la seconde UE
|
||||
return self.ue2.niveau_competence
|
||||
# à défaut (si l'UE a été désacciée entre temps), la première
|
||||
# et à défaut, renvoie None
|
||||
return self.ue2.niveau_competence or self.ue1.niveau_competence
|
||||
|
||||
def to_dict(self):
|
||||
"as a dict"
|
||||
|
@ -161,7 +164,7 @@ class ApcValidationAnnee(db.Model):
|
|||
def html(self) -> str:
|
||||
"Affichage html"
|
||||
date_str = (
|
||||
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
|
||||
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
|
||||
if self.date
|
||||
else "(sans date)"
|
||||
)
|
||||
|
@ -218,15 +221,18 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
|
|||
decisions["descr_decisions_rcue"] = ""
|
||||
decisions["descr_decisions_niveaux"] = ""
|
||||
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
validation = ApcValidationAnnee.query.filter_by(
|
||||
etudid=etud.id,
|
||||
annee_scolaire=formsemestre.annee_scolaire(),
|
||||
ordre=annee_but,
|
||||
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
if validation:
|
||||
decisions["decision_annee"] = validation.to_dict_bul()
|
||||
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
validation = ApcValidationAnnee.query.filter_by(
|
||||
etudid=etud.id,
|
||||
annee_scolaire=formsemestre.annee_scolaire(),
|
||||
ordre=annee_but,
|
||||
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
if validation:
|
||||
decisions["decision_annee"] = validation.to_dict_bul()
|
||||
else:
|
||||
decisions["decision_annee"] = None
|
||||
else:
|
||||
decisions["decision_annee"] = None
|
||||
return decisions
|
||||
|
|
|
@ -92,9 +92,11 @@ class ScoDocSiteConfig(db.Model):
|
|||
"INSTITUTION_CITY": str,
|
||||
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
|
||||
"enable_entreprises": bool,
|
||||
"disable_passerelle": bool, # remplace pref. bul_display_publication
|
||||
"month_debut_annee_scolaire": int,
|
||||
"month_debut_periode2": int,
|
||||
"disable_bul_pdf": bool,
|
||||
"user_require_email_institutionnel": bool,
|
||||
# CAS
|
||||
"cas_enable": bool,
|
||||
"cas_server": str,
|
||||
|
@ -231,12 +233,32 @@ class ScoDocSiteConfig(db.Model):
|
|||
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_cas_forced(cls) -> bool:
|
||||
"""True si CAS forcé"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="cas_force").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_entreprises_enabled(cls) -> bool:
|
||||
"""True si on doit activer le module entreprise"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_passerelle_disabled(cls):
|
||||
"""True si on doit cacher les fonctions passerelle ("oeil")."""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_user_require_email_institutionnel_enabled(cls) -> bool:
|
||||
"""True si impose saisie email_institutionnel"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(
|
||||
name="user_require_email_institutionnel"
|
||||
).first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_bul_pdf_disabled(cls) -> bool:
|
||||
"""True si on interdit les exports PDF des bulltins"""
|
||||
|
@ -244,36 +266,19 @@ class ScoDocSiteConfig(db.Model):
|
|||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def enable_entreprises(cls, enabled=True) -> bool:
|
||||
def enable_entreprises(cls, enabled: bool = True) -> bool:
|
||||
"""Active (ou déactive) le module entreprises. True si changement."""
|
||||
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
|
||||
if cfg is None:
|
||||
cfg = ScoDocSiteConfig(
|
||||
name="enable_entreprises", value="on" if enabled else ""
|
||||
)
|
||||
else:
|
||||
cfg.value = "on" if enabled else ""
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
return cls.set("enable_entreprises", "on" if enabled else "")
|
||||
|
||||
@classmethod
|
||||
def disable_passerelle(cls, disabled: bool = True) -> bool:
|
||||
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
|
||||
return cls.set("disable_passerelle", "on" if disabled else "")
|
||||
|
||||
@classmethod
|
||||
def disable_bul_pdf(cls, enabled=True) -> bool:
|
||||
"""Interedit (ou autorise) les exports PDF. True si changement."""
|
||||
if enabled != ScoDocSiteConfig.is_bul_pdf_disabled():
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
|
||||
if cfg is None:
|
||||
cfg = ScoDocSiteConfig(
|
||||
name="disable_bul_pdf", value="on" if enabled else ""
|
||||
)
|
||||
else:
|
||||
cfg.value = "on" if enabled else ""
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
"""Interdit (ou autorise) les exports PDF. True si changement."""
|
||||
return cls.set("disable_bul_pdf", "on" if enabled else "")
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str, default: str = "") -> str:
|
||||
|
@ -292,9 +297,10 @@ class ScoDocSiteConfig(db.Model):
|
|||
if cfg is None:
|
||||
cfg = ScoDocSiteConfig(name=name, value=value_str)
|
||||
else:
|
||||
cfg.value = str(value or "")
|
||||
cfg.value = value_str
|
||||
current_app.logger.info(
|
||||
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
|
||||
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
|
||||
'...' if len(cfg.value)>32 else ''}'"""
|
||||
)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
|
@ -303,7 +309,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
|
||||
@classmethod
|
||||
def _get_int_field(cls, name: str, default=None) -> int:
|
||||
"""Valeur d'un champs integer"""
|
||||
"""Valeur d'un champ integer"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if (cfg is None) or cfg.value is None:
|
||||
return default
|
||||
|
@ -317,7 +323,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
default=None,
|
||||
range_values: tuple = (),
|
||||
) -> bool:
|
||||
"""Set champs integer. True si changement."""
|
||||
"""Set champ integer. True si changement."""
|
||||
if value != cls._get_int_field(name, default=default):
|
||||
if not isinstance(value, int) or (
|
||||
range_values and (value < range_values[0]) or (value > range_values[1])
|
||||
|
|
|
@ -19,7 +19,7 @@ from app.models.departements import Departement
|
|||
from app.models.scolar_event import ScolarEvent
|
||||
from app.scodoc import notesdb as ndb
|
||||
from app.scodoc.sco_bac import Baccalaureat
|
||||
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
|
@ -101,7 +101,12 @@ class Identite(models.ScoDocModel):
|
|||
adresses = db.relationship(
|
||||
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
|
||||
)
|
||||
|
||||
annotations = db.relationship(
|
||||
"EtudAnnotation",
|
||||
backref="etudiant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic",
|
||||
)
|
||||
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
|
||||
#
|
||||
dispense_ues = db.relationship(
|
||||
|
@ -119,6 +124,9 @@ class Identite(models.ScoDocModel):
|
|||
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
|
||||
)
|
||||
|
||||
# Champs "protégés" par ViewEtudData (RGPD)
|
||||
protected_attrs = {"boursier", "nationalite"}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
|
||||
|
@ -176,7 +184,7 @@ class Identite(models.ScoDocModel):
|
|||
def url_fiche(self) -> str:
|
||||
"url de la fiche étudiant"
|
||||
return url_for(
|
||||
"scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id
|
||||
"scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
@ -230,6 +238,15 @@ class Identite(models.ScoDocModel):
|
|||
log(f"Identite.create {etud}")
|
||||
return etud
|
||||
|
||||
def from_dict(self, args, **kwargs) -> bool:
|
||||
"""Check arguments, then modify.
|
||||
Add to session but don't commit.
|
||||
True if modification.
|
||||
"""
|
||||
check_etud_duplicate_code(args, "code_nip")
|
||||
check_etud_duplicate_code(args, "code_ine")
|
||||
return super().from_dict(args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
|
||||
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
|
||||
|
@ -280,7 +297,7 @@ class Identite(models.ScoDocModel):
|
|||
else:
|
||||
return self.nom
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def nomprenom(self, reverse=False) -> str:
|
||||
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
|
||||
Si reverse, "Dupont Pierre", sans civilité.
|
||||
|
@ -317,16 +334,14 @@ class Identite(models.ScoDocModel):
|
|||
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
|
||||
|
||||
@cached_property
|
||||
def sort_key(self) -> tuple:
|
||||
def sort_key(self) -> str:
|
||||
"clé pour tris par ordre alphabétique"
|
||||
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
|
||||
# si on modifie cette méthode.
|
||||
return (
|
||||
scu.sanitize_string(
|
||||
self.nom_usuel or self.nom or "", remove_spaces=False
|
||||
).lower(),
|
||||
scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
|
||||
)
|
||||
return scu.sanitize_string(
|
||||
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
|
||||
remove_spaces=False,
|
||||
).lower()
|
||||
|
||||
def get_first_email(self, field="email") -> str:
|
||||
"Le mail associé à la première adresse de l'étudiant, ou None"
|
||||
|
@ -350,8 +365,8 @@ class Identite(models.ScoDocModel):
|
|||
{ formsemestre_id : [ modimpl, ... ] }
|
||||
annee_scolaire est un nombre: eg 2023
|
||||
"""
|
||||
date_debut_annee = scu.date_debut_anne_scolaire(annee_scolaire)
|
||||
date_fin_annee = scu.date_fin_anne_scolaire(annee_scolaire)
|
||||
date_debut_annee = scu.date_debut_annee_scolaire(annee_scolaire)
|
||||
date_fin_annee = scu.date_fin_annee_scolaire(annee_scolaire)
|
||||
modimpls = (
|
||||
ModuleImpl.query.join(ModuleImplInscription)
|
||||
.join(FormSemestre)
|
||||
|
@ -418,7 +433,7 @@ class Identite(models.ScoDocModel):
|
|||
return args_dict
|
||||
|
||||
def to_dict_short(self) -> dict:
|
||||
"""Les champs essentiels"""
|
||||
"""Les champs essentiels (aucune donnée perso protégée)"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"civilite": self.civilite,
|
||||
|
@ -433,9 +448,11 @@ class Identite(models.ScoDocModel):
|
|||
"prenom_etat_civil": self.prenom_etat_civil,
|
||||
}
|
||||
|
||||
def to_dict_scodoc7(self) -> dict:
|
||||
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
|
||||
"""Représentation dictionnaire,
|
||||
compatible ScoDoc7 mais sans infos admission
|
||||
compatible ScoDoc7 mais sans infos admission.
|
||||
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
|
||||
Si with_inscriptions, inclut les champs "inscription"
|
||||
"""
|
||||
e_dict = self.__dict__.copy() # dict(self.__dict__)
|
||||
e_dict.pop("_sa_instance_state", None)
|
||||
|
@ -446,7 +463,9 @@ class Identite(models.ScoDocModel):
|
|||
e_dict["nomprenom"] = self.nomprenom
|
||||
adresse = self.adresses.first()
|
||||
if adresse:
|
||||
e_dict.update(adresse.to_dict())
|
||||
e_dict.update(adresse.to_dict(restrict=restrict))
|
||||
if with_inscriptions:
|
||||
e_dict.update(self.inscription_descr())
|
||||
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
|
||||
|
||||
def to_dict_bul(self, include_urls=True):
|
||||
|
@ -461,9 +480,11 @@ class Identite(models.ScoDocModel):
|
|||
"civilite": self.civilite,
|
||||
"code_ine": self.code_ine or "",
|
||||
"code_nip": self.code_nip or "",
|
||||
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
|
||||
if self.date_naissance
|
||||
else "",
|
||||
"date_naissance": (
|
||||
self.date_naissance.strftime(scu.DATE_FMT)
|
||||
if self.date_naissance
|
||||
else ""
|
||||
),
|
||||
"dept_acronym": self.departement.acronym,
|
||||
"dept_id": self.dept_id,
|
||||
"dept_naissance": self.dept_naissance or "",
|
||||
|
@ -481,7 +502,7 @@ class Identite(models.ScoDocModel):
|
|||
if include_urls and has_request_context():
|
||||
# test request context so we can use this func in tests under the flask shell
|
||||
d["fiche_url"] = url_for(
|
||||
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
|
||||
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
|
||||
)
|
||||
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
|
||||
adresse = self.adresses.first()
|
||||
|
@ -490,22 +511,37 @@ class Identite(models.ScoDocModel):
|
|||
d["id"] = self.id # a été écrasé par l'id de adresse
|
||||
return d
|
||||
|
||||
def to_dict_api(self) -> dict:
|
||||
"""Représentation dictionnaire pour export API, avec adresses et admission."""
|
||||
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
|
||||
"""Représentation dictionnaire pour export API, avec adresses et admission.
|
||||
Si restrict, supprime les infos "personnelles" (boursier)
|
||||
"""
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
admission = self.admission
|
||||
e["admission"] = admission.to_dict() if admission is not None else None
|
||||
e["adresses"] = [adr.to_dict() for adr in self.adresses]
|
||||
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
|
||||
e["dept_acronym"] = self.departement.acronym
|
||||
e.pop("departement", None)
|
||||
e["sort_key"] = self.sort_key
|
||||
if with_annotations:
|
||||
e["annotations"] = (
|
||||
[
|
||||
annot.to_dict()
|
||||
for annot in EtudAnnotation.query.filter_by(
|
||||
etudid=self.id
|
||||
).order_by(desc(EtudAnnotation.date))
|
||||
]
|
||||
if not restrict
|
||||
else []
|
||||
)
|
||||
if restrict:
|
||||
# Met à None les attributs protégés:
|
||||
for attr in self.protected_attrs:
|
||||
e[attr] = None
|
||||
return e
|
||||
|
||||
def inscriptions(self) -> list["FormSemestreInscription"]:
|
||||
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
|
||||
return (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
|
@ -531,8 +567,6 @@ class Identite(models.ScoDocModel):
|
|||
(il est rare qu'il y en ai plus d'une, mais c'est possible).
|
||||
Triées par date de début de semestre décroissante (le plus récent en premier).
|
||||
"""
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
|
||||
return (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
|
@ -555,7 +589,9 @@ class Identite(models.ScoDocModel):
|
|||
return r[0] if r else None
|
||||
|
||||
def inscription_descr(self) -> dict:
|
||||
"""Description de l'état d'inscription"""
|
||||
"""Description de l'état d'inscription
|
||||
avec champs compatibles templates ScoDoc7
|
||||
"""
|
||||
inscription_courante = self.inscription_courante()
|
||||
if inscription_courante:
|
||||
titre_sem = inscription_courante.formsemestre.titre_mois()
|
||||
|
@ -566,7 +602,7 @@ class Identite(models.ScoDocModel):
|
|||
else:
|
||||
inscr_txt = "Inscrit en"
|
||||
|
||||
return {
|
||||
result = {
|
||||
"etat_in_cursem": inscription_courante.etat,
|
||||
"inscription_courante": inscription_courante,
|
||||
"inscription": titre_sem,
|
||||
|
@ -589,15 +625,20 @@ class Identite(models.ScoDocModel):
|
|||
inscription = "ancien"
|
||||
situation = "ancien élève"
|
||||
else:
|
||||
inscription = ("non inscrit",)
|
||||
inscription = "non inscrit"
|
||||
situation = inscription
|
||||
return {
|
||||
result = {
|
||||
"etat_in_cursem": "?",
|
||||
"inscription_courante": None,
|
||||
"inscription": inscription,
|
||||
"inscription_str": inscription,
|
||||
"situation": situation,
|
||||
}
|
||||
# aliases pour compat templates ScoDoc7
|
||||
result["etatincursem"] = result["etat_in_cursem"]
|
||||
result["inscriptionstr"] = result["inscription_str"]
|
||||
|
||||
return result
|
||||
|
||||
def inscription_etat(self, formsemestre_id: int) -> str:
|
||||
"""État de l'inscription de cet étudiant au semestre:
|
||||
|
@ -694,7 +735,7 @@ class Identite(models.ScoDocModel):
|
|||
"""
|
||||
if with_paragraph:
|
||||
return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {
|
||||
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
|
||||
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
|
||||
line_sep}à {self.lieu_naissance or ""}"""
|
||||
return self.etat_civil
|
||||
|
||||
|
@ -718,6 +759,58 @@ class Identite(models.ScoDocModel):
|
|||
)
|
||||
|
||||
|
||||
def check_etud_duplicate_code(args, code_name, edit=True):
|
||||
"""Vérifie que le code n'est pas dupliqué.
|
||||
Raises ScoGenError si problème.
|
||||
"""
|
||||
etudid = args.get("etudid", None)
|
||||
if not args.get(code_name, None):
|
||||
return
|
||||
etuds = Identite.query.filter_by(
|
||||
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
|
||||
).all()
|
||||
duplicate = False
|
||||
if edit:
|
||||
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
|
||||
else:
|
||||
duplicate = len(etuds) > 0
|
||||
if duplicate:
|
||||
listh = [] # liste des doubles
|
||||
for etud in etuds:
|
||||
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
|
||||
if etudid:
|
||||
submit_label = "retour à la fiche étudiant"
|
||||
dest_endpoint = "scolar.fiche_etud"
|
||||
parameters = {"etudid": etudid}
|
||||
else:
|
||||
if "tf_submitted" in args:
|
||||
del args["tf_submitted"]
|
||||
submit_label = "Continuer"
|
||||
dest_endpoint = "scolar.etudident_create_form"
|
||||
parameters = args
|
||||
else:
|
||||
submit_label = "Annuler"
|
||||
dest_endpoint = "notes.index_html"
|
||||
parameters = {}
|
||||
|
||||
err_page = f"""<h3><h3>Code étudiant ({code_name}) dupliqué !</h3>
|
||||
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
|
||||
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
|
||||
</p>
|
||||
<ul><li>
|
||||
{ '</li><li>'.join(listh) }
|
||||
</li></ul>
|
||||
<p>
|
||||
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
|
||||
">{submit_label}</a>
|
||||
</p>
|
||||
"""
|
||||
|
||||
log(f"*** error: code {code_name} duplique: {args[code_name]}")
|
||||
|
||||
raise ScoGenError(err_page)
|
||||
|
||||
|
||||
def make_etud_args(
|
||||
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
|
||||
) -> dict:
|
||||
|
@ -825,12 +918,25 @@ class Adresse(models.ScoDocModel):
|
|||
)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
def to_dict(self, convert_nulls_to_str=False):
|
||||
"""Représentation dictionnaire,"""
|
||||
# Champs "protégés" par ViewEtudData (RGPD)
|
||||
protected_attrs = {
|
||||
"emailperso",
|
||||
"domicile",
|
||||
"codepostaldomicile",
|
||||
"villedomicile",
|
||||
"telephone",
|
||||
"telephonemobile",
|
||||
"fax",
|
||||
}
|
||||
|
||||
def to_dict(self, convert_nulls_to_str=False, restrict=False):
|
||||
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
if convert_nulls_to_str:
|
||||
return {k: e[k] or "" for k in e}
|
||||
e = {k: v or "" for k, v in e.items()}
|
||||
if restrict:
|
||||
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
|
||||
return e
|
||||
|
||||
|
||||
|
@ -885,12 +991,16 @@ class Admission(models.ScoDocModel):
|
|||
# classement (1..Ngr) par le jury dans le groupe APB
|
||||
apb_classement_gr = db.Column(db.Integer)
|
||||
|
||||
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
|
||||
# sauf:
|
||||
not_protected_attrs = {"bac", "specialite", "anne_bac"}
|
||||
|
||||
def get_bac(self) -> Baccalaureat:
|
||||
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
|
||||
return Baccalaureat(self.bac, specialite=self.specialite)
|
||||
|
||||
def to_dict(self, no_nulls=False):
|
||||
"""Représentation dictionnaire,"""
|
||||
def to_dict(self, no_nulls=False, restrict=False):
|
||||
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
if no_nulls:
|
||||
|
@ -905,6 +1015,8 @@ class Admission(models.ScoDocModel):
|
|||
d[key] = 0
|
||||
elif isinstance(col_type, sqlalchemy.Boolean):
|
||||
d[key] = False
|
||||
if restrict:
|
||||
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
|
@ -972,11 +1084,16 @@ class EtudAnnotation(db.Model):
|
|||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
|
||||
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
|
||||
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
|
||||
comment = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
"""Représentation dictionnaire."""
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
return e
|
||||
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.modules import Module
|
||||
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
|
|
|
@ -5,11 +5,12 @@
|
|||
import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, url_for
|
||||
from flask import abort, g, url_for
|
||||
from flask_login import current_user
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app import db, log
|
||||
from app import models
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.events import ScolarNews
|
||||
from app.models.notes import NotesNotes
|
||||
|
@ -23,10 +24,8 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
|
|||
NOON = datetime.time(12, 00)
|
||||
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
|
||||
|
||||
VALID_EVALUATION_TYPES = {0, 1, 2}
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
class Evaluation(models.ScoDocModel):
|
||||
"""Evaluation (contrôle, examen, ...)"""
|
||||
|
||||
__tablename__ = "notes_evaluation"
|
||||
|
@ -38,9 +37,9 @@ class Evaluation(db.Model):
|
|||
)
|
||||
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
description = db.Column(db.Text)
|
||||
note_max = db.Column(db.Float)
|
||||
coefficient = db.Column(db.Float)
|
||||
description = db.Column(db.Text, nullable=False)
|
||||
note_max = db.Column(db.Float, nullable=False)
|
||||
coefficient = db.Column(db.Float, nullable=False)
|
||||
visibulletin = db.Column(
|
||||
db.Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
|
@ -48,15 +47,30 @@ class Evaluation(db.Model):
|
|||
publish_incomplete = db.Column(
|
||||
db.Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
|
||||
"prise en compte immédiate"
|
||||
evaluation_type = db.Column(
|
||||
db.Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
"type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
|
||||
blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
"date de prise en compte"
|
||||
BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
|
||||
# ordre de presentation (par défaut, le plus petit numero
|
||||
# est la plus ancienne eval):
|
||||
numero = db.Column(db.Integer, nullable=False, default=0)
|
||||
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
|
||||
|
||||
EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer !
|
||||
EVALUATION_RATTRAPAGE = 1
|
||||
EVALUATION_SESSION2 = 2
|
||||
EVALUATION_BONUS = 3
|
||||
VALID_EVALUATION_TYPES = {
|
||||
EVALUATION_NORMALE,
|
||||
EVALUATION_RATTRAPAGE,
|
||||
EVALUATION_SESSION2,
|
||||
EVALUATION_BONUS,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"""<Evaluation {self.id} {
|
||||
self.date_debut.isoformat() if self.date_debut else ''} "{
|
||||
|
@ -70,15 +84,17 @@ class Evaluation(db.Model):
|
|||
date_fin: datetime.datetime = None,
|
||||
description=None,
|
||||
note_max=None,
|
||||
blocked_until=None,
|
||||
coefficient=None,
|
||||
visibulletin=None,
|
||||
publish_incomplete=None,
|
||||
evaluation_type=None,
|
||||
numero=None,
|
||||
**kw, # ceci pour absorber les éventuel arguments excedentaires
|
||||
):
|
||||
) -> "Evaluation":
|
||||
"""Create an evaluation. Check permission and all arguments.
|
||||
Ne crée pas les poids vers les UEs.
|
||||
Add to session, do not commit.
|
||||
"""
|
||||
if not moduleimpl.can_edit_evaluation(current_user):
|
||||
raise AccessDenied(
|
||||
|
@ -87,13 +103,15 @@ class Evaluation(db.Model):
|
|||
args = locals()
|
||||
del args["cls"]
|
||||
del args["kw"]
|
||||
check_convert_evaluation_args(moduleimpl, args)
|
||||
check_and_convert_evaluation_args(args, moduleimpl)
|
||||
# Check numeros
|
||||
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
|
||||
if not "numero" in args or args["numero"] is None:
|
||||
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
|
||||
#
|
||||
evaluation = Evaluation(**args)
|
||||
db.session.add(evaluation)
|
||||
db.session.flush()
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
|
||||
url = url_for(
|
||||
"notes.moduleimpl_status",
|
||||
|
@ -184,18 +202,24 @@ class Evaluation(db.Model):
|
|||
# ScoDoc7 output_formators
|
||||
e_dict["evaluation_id"] = self.id
|
||||
e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None
|
||||
e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None
|
||||
e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None
|
||||
e_dict["numero"] = self.numero or 0
|
||||
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
|
||||
|
||||
# Deprecated
|
||||
e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
|
||||
e_dict["jour"] = (
|
||||
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
|
||||
)
|
||||
|
||||
return evaluation_enrich_dict(self, e_dict)
|
||||
|
||||
def to_dict_api(self) -> dict:
|
||||
"Représentation dict pour API JSON"
|
||||
return {
|
||||
"blocked": self.is_blocked(),
|
||||
"blocked_until": (
|
||||
self.blocked_until.isoformat() if self.blocked_until else ""
|
||||
),
|
||||
"coefficient": self.coefficient,
|
||||
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
|
||||
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
|
||||
|
@ -210,9 +234,9 @@ class Evaluation(db.Model):
|
|||
"visibulletin": self.visibulletin,
|
||||
# Deprecated (supprimer avant #sco9.7)
|
||||
"date": self.date_debut.date().isoformat() if self.date_debut else "",
|
||||
"heure_debut": self.date_debut.time().isoformat()
|
||||
if self.date_debut
|
||||
else "",
|
||||
"heure_debut": (
|
||||
self.date_debut.time().isoformat() if self.date_debut else ""
|
||||
),
|
||||
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
|
||||
}
|
||||
|
||||
|
@ -232,14 +256,24 @@ class Evaluation(db.Model):
|
|||
|
||||
return e_dict
|
||||
|
||||
def from_dict(self, data):
|
||||
"""Set evaluation attributes from given dict values."""
|
||||
check_convert_evaluation_args(self.moduleimpl, data)
|
||||
if data.get("numero") is None:
|
||||
data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
|
||||
for k in self.__dict__:
|
||||
if k != "_sa_instance_state" and k != "id" and k in data:
|
||||
setattr(self, k, data[k])
|
||||
@classmethod
|
||||
def get_evaluation(
|
||||
cls, evaluation_id: int | str, dept_id: int = None
|
||||
) -> "Evaluation":
|
||||
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
from app.models import FormSemestre, ModuleImpl
|
||||
|
||||
if not isinstance(evaluation_id, int):
|
||||
try:
|
||||
evaluation_id = int(evaluation_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "evaluation_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
query = cls.query.filter_by(id=evaluation_id)
|
||||
if dept_id is not None:
|
||||
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
|
||||
return query.first_or_404()
|
||||
|
||||
@classmethod
|
||||
def get_max_numero(cls, moduleimpl_id: int) -> int:
|
||||
|
@ -265,7 +299,9 @@ class Evaluation(db.Model):
|
|||
evaluations = moduleimpl.evaluations.order_by(
|
||||
Evaluation.date_debut, Evaluation.numero
|
||||
).all()
|
||||
all_numbered = all(e.numero is not None for e in evaluations)
|
||||
numeros_distincts = {e.numero for e in evaluations if e.numero is not None}
|
||||
# pas de None, pas de dupliqués
|
||||
all_numbered = len(numeros_distincts) == len(evaluations)
|
||||
if all_numbered and only_if_unumbered:
|
||||
return # all ok
|
||||
|
||||
|
@ -281,10 +317,10 @@ class Evaluation(db.Model):
|
|||
def descr_heure(self) -> str:
|
||||
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')"
|
||||
if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut):
|
||||
return f"""à {self.date_debut.strftime("%Hh%M")}"""
|
||||
return f"""à {self.date_debut.strftime(scu.TIME_FMT)}"""
|
||||
elif self.date_debut and self.date_fin:
|
||||
return f"""de {self.date_debut.strftime("%Hh%M")
|
||||
} à {self.date_fin.strftime("%Hh%M")}"""
|
||||
return f"""de {self.date_debut.strftime(scu.TIME_FMT)
|
||||
} à {self.date_fin.strftime(scu.TIME_FMT)}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
@ -311,7 +347,7 @@ class Evaluation(db.Model):
|
|||
|
||||
def _h(dt: datetime.datetime) -> str:
|
||||
if dt.minute:
|
||||
return dt.strftime("%Hh%M")
|
||||
return dt.strftime(scu.TIME_FMT)
|
||||
return f"{dt.hour}h"
|
||||
|
||||
if self.date_fin is None:
|
||||
|
@ -337,19 +373,6 @@ class Evaluation(db.Model):
|
|||
Chaine vide si non renseignée."""
|
||||
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
|
||||
|
||||
def clone(self, not_copying=()):
|
||||
"""Clone, not copying the given attrs
|
||||
Attention: la copie n'a pas d'id avant le prochain commit
|
||||
"""
|
||||
d = dict(self.__dict__)
|
||||
d.pop("id") # get rid of id
|
||||
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
|
||||
for k in not_copying:
|
||||
d.pop(k)
|
||||
copy = self.__class__(**d)
|
||||
db.session.add(copy)
|
||||
return copy
|
||||
|
||||
def is_matin(self) -> bool:
|
||||
"Evaluation commençant le matin (faux si pas de date)"
|
||||
if not self.date_debut:
|
||||
|
@ -362,6 +385,14 @@ class Evaluation(db.Model):
|
|||
return False
|
||||
return self.date_debut.time() >= NOON
|
||||
|
||||
def is_blocked(self, now=None) -> bool:
|
||||
"True si prise en compte bloquée"
|
||||
if self.blocked_until is None:
|
||||
return False
|
||||
if now is None:
|
||||
now = datetime.datetime.now(scu.TIME_ZONE)
|
||||
return self.blocked_until > now
|
||||
|
||||
def set_default_poids(self) -> bool:
|
||||
"""Initialize les poids vers les UE à leurs valeurs par défaut
|
||||
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
|
||||
|
@ -428,8 +459,8 @@ class Evaluation(db.Model):
|
|||
|
||||
def get_ue_poids_str(self) -> str:
|
||||
"""string describing poids, for excel cells and pdfs
|
||||
Note: si les poids ne sont pas initialisés (poids par défaut),
|
||||
ils ne sont pas affichés.
|
||||
Note: les poids nuls ou non initialisés (poids par défaut),
|
||||
ne sont pas affichés.
|
||||
"""
|
||||
# restreint aux UE du semestre dans lequel est cette évaluation
|
||||
# au cas où le module ait changé de semestre et qu'il reste des poids
|
||||
|
@ -440,7 +471,7 @@ class Evaluation(db.Model):
|
|||
for p in sorted(
|
||||
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
|
||||
)
|
||||
if evaluation_semestre_idx == p.ue.semestre_idx
|
||||
if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -450,6 +481,29 @@ class Evaluation(db.Model):
|
|||
"""
|
||||
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
|
||||
|
||||
@classmethod
|
||||
def get_evaluations_blocked_for_etud(
|
||||
cls, formsemestre, etud: Identite
|
||||
) -> list["Evaluation"]:
|
||||
"""Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage
|
||||
et date blocage < FOREVER.
|
||||
Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut
|
||||
donc interdire la saisie du jury.
|
||||
"""
|
||||
now = datetime.datetime.now(scu.TIME_ZONE)
|
||||
return (
|
||||
Evaluation.query.filter(
|
||||
Evaluation.blocked_until != None, # pylint: disable=C0121
|
||||
Evaluation.blocked_until >= now,
|
||||
)
|
||||
.join(ModuleImpl)
|
||||
.filter_by(formsemestre_id=formsemestre.id)
|
||||
.join(ModuleImplInscription)
|
||||
.filter_by(etudid=etud.id)
|
||||
.join(NotesNotes)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class EvaluationUEPoids(db.Model):
|
||||
"""Poids des évaluations (BUT)
|
||||
|
@ -487,8 +541,8 @@ class EvaluationUEPoids(db.Model):
|
|||
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
|
||||
"""add or convert some fields in an evaluation dict"""
|
||||
# For ScoDoc7 compat
|
||||
e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
|
||||
e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else ""
|
||||
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
|
||||
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
|
||||
e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
|
||||
# Calcule durée en minutes
|
||||
e_dict["descrheure"] = e.descr_heure()
|
||||
|
@ -507,7 +561,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
|
|||
return e_dict
|
||||
|
||||
|
||||
def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
||||
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
|
||||
"""Check coefficient, dates and duration, raises exception if invalid.
|
||||
Convert date and time strings to date and time objects.
|
||||
|
||||
|
@ -522,7 +576,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
# --- evaluation_type
|
||||
try:
|
||||
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
|
||||
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
|
||||
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
|
||||
raise ScoValueError("invalid evaluation_type value")
|
||||
except ValueError as exc:
|
||||
raise ScoValueError("invalid evaluation_type value") from exc
|
||||
|
@ -547,7 +601,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
if coef < 0:
|
||||
raise ScoValueError("invalid coefficient value (must be positive or null)")
|
||||
data["coefficient"] = coef
|
||||
# --- date de l'évaluation
|
||||
# --- date de l'évaluation dans le semestre ?
|
||||
formsemestre = moduleimpl.formsemestre
|
||||
date_debut = data.get("date_debut", None)
|
||||
if date_debut:
|
||||
|
@ -562,7 +616,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
):
|
||||
raise ScoValueError(
|
||||
f"""La date de début de l'évaluation ({
|
||||
data["date_debut"].strftime("%d/%m/%Y")
|
||||
data["date_debut"].strftime(scu.DATE_FMT)
|
||||
}) n'est pas dans le semestre !""",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
|
@ -577,27 +631,19 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
):
|
||||
raise ScoValueError(
|
||||
f"""La date de fin de l'évaluation ({
|
||||
data["date_fin"].strftime("%d/%m/%Y")
|
||||
data["date_fin"].strftime(scu.DATE_FMT)
|
||||
}) n'est pas dans le semestre !""",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
if date_debut and date_fin:
|
||||
duration = data["date_fin"] - data["date_debut"]
|
||||
if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION:
|
||||
raise ScoValueError("Heures de l'évaluation incohérentes !")
|
||||
# # --- heures
|
||||
# heure_debut = data.get("heure_debut", None)
|
||||
# if heure_debut and not isinstance(heure_debut, datetime.time):
|
||||
# if date_format == "dmy":
|
||||
# data["heure_debut"] = heure_to_time(heure_debut)
|
||||
# else: # ISO
|
||||
# data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
|
||||
# heure_fin = data.get("heure_fin", None)
|
||||
# if heure_fin and not isinstance(heure_fin, datetime.time):
|
||||
# if date_format == "dmy":
|
||||
# data["heure_fin"] = heure_to_time(heure_fin)
|
||||
# else: # ISO
|
||||
# data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
|
||||
raise ScoValueError(
|
||||
"Heures de l'évaluation incohérentes !",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
if "blocked_until" in data:
|
||||
data["blocked_until"] = data["blocked_until"] or None
|
||||
|
||||
|
||||
def heure_to_time(heure: str) -> datetime.time:
|
||||
|
@ -627,3 +673,6 @@ def _moduleimpl_evaluation_insert_before(
|
|||
db.session.add(e)
|
||||
db.session.commit()
|
||||
return n
|
||||
|
||||
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
|
|
|
@ -232,7 +232,9 @@ class ScolarNews(db.Model):
|
|||
)
|
||||
|
||||
# Transforme les URL en URL absolues
|
||||
base = scu.ScoURL()
|
||||
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
|
||||
: -len("/index_html")
|
||||
]
|
||||
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
|
||||
|
||||
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
|
||||
|
@ -249,11 +251,12 @@ class ScolarNews(db.Model):
|
|||
news_list = cls.last_news(n=n)
|
||||
if not news_list:
|
||||
return ""
|
||||
dept_news_url = url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
|
||||
H = [
|
||||
f"""<div class="news"><span class="newstitle"><a href="{
|
||||
url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
|
||||
f"""<div class="scobox news"><div class="scobox-title"><a href="{
|
||||
dept_news_url
|
||||
}">Dernières opérations</a>
|
||||
</span><ul class="newslist">"""
|
||||
</div><ul class="newslist">"""
|
||||
]
|
||||
|
||||
for news in news_list:
|
||||
|
@ -261,16 +264,22 @@ class ScolarNews(db.Model):
|
|||
f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
|
||||
class="newstext">{news}</span></li>"""
|
||||
)
|
||||
H.append(
|
||||
f"""<li class="newslist">
|
||||
<span class="newstext"><a href="{dept_news_url}" class="stdlink">...</a>
|
||||
</span>
|
||||
</li>"""
|
||||
)
|
||||
|
||||
H.append("</ul>")
|
||||
H.append("</ul></div>")
|
||||
|
||||
# Informations générales
|
||||
H.append(
|
||||
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
|
||||
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
|
||||
f"""<div>
|
||||
Pour en savoir plus sur ScoDoc voir
|
||||
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
H.append("</div>")
|
||||
return "\n".join(H)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""ScoDoc 9 models : Formations
|
||||
"""
|
||||
|
||||
from flask import abort, g
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
import app
|
||||
|
@ -64,6 +66,21 @@ class Formation(db.Model):
|
|||
"titre complet pour affichage"
|
||||
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
|
||||
|
||||
@classmethod
|
||||
def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
|
||||
"""Formation ou 404, cherche uniquement dans le département spécifié
|
||||
ou le courant (g.scodoc_dept)"""
|
||||
if not isinstance(formation_id, int):
|
||||
try:
|
||||
formation_id = int(formation_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "formation_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
if dept_id is not None:
|
||||
return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404()
|
||||
return cls.query.filter_by(id=formation_id).first_or_404()
|
||||
|
||||
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
|
||||
"""As a dict.
|
||||
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -10,19 +10,22 @@
|
|||
|
||||
"""ScoDoc models: formsemestre
|
||||
"""
|
||||
from collections import defaultdict
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
from itertools import chain
|
||||
from operator import attrgetter
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from flask import flash, g, url_for
|
||||
from flask import abort, flash, g, url_for
|
||||
from sqlalchemy.sql import text
|
||||
from sqlalchemy import func
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import db, log
|
||||
from app.auth.models import User
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
|
||||
from app.models.but_refcomp import (
|
||||
ApcParcours,
|
||||
|
@ -35,7 +38,11 @@ from app.models.etudiants import Identite
|
|||
from app.models.evaluations import Evaluation
|
||||
from app.models.formations import Formation
|
||||
from app.models.groups import GroupDescr, Partition
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
from app.models.moduleimpls import (
|
||||
ModuleImpl,
|
||||
ModuleImplInscription,
|
||||
notes_modules_enseignants,
|
||||
)
|
||||
from app.models.modules import Module
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.validations import ScolarFormSemestreValidation
|
||||
|
@ -45,12 +52,10 @@ from app.scodoc.sco_permissions import Permission
|
|||
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
|
||||
from app.scodoc.sco_vdi import ApoEtapeVDI
|
||||
|
||||
from app.scodoc.sco_utils import translate_assiduites_metric
|
||||
|
||||
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
|
||||
|
||||
|
||||
class FormSemestre(db.Model):
|
||||
class FormSemestre(models.ScoDocModel):
|
||||
"""Mise en oeuvre d'un semestre de formation"""
|
||||
|
||||
__tablename__ = "notes_formsemestre"
|
||||
|
@ -64,7 +69,7 @@ class FormSemestre(db.Model):
|
|||
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
||||
titre = db.Column(db.Text(), nullable=False)
|
||||
date_debut = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
|
||||
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
|
||||
"identifiant emplois du temps (unicité non imposée)"
|
||||
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
|
||||
|
@ -80,7 +85,7 @@ class FormSemestre(db.Model):
|
|||
bul_hide_xml = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"ne publie pas le bulletin XML ou JSON"
|
||||
"ne publie pas le bulletin sur l'API"
|
||||
block_moyennes = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
@ -89,6 +94,10 @@ class FormSemestre(db.Model):
|
|||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
|
||||
mode_calcul_moyennes = db.Column(
|
||||
db.Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
"pour usage futur"
|
||||
gestion_semestrielle = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
@ -181,9 +190,15 @@ class FormSemestre(db.Model):
|
|||
|
||||
@classmethod
|
||||
def get_formsemestre(
|
||||
cls, formsemestre_id: int, dept_id: int = None
|
||||
cls, formsemestre_id: int | str, dept_id: int = None
|
||||
) -> "FormSemestre":
|
||||
""" "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
|
||||
"""FormSemestre ou 404, cherche uniquement dans le département spécifié
|
||||
ou le courant (g.scodoc_dept)"""
|
||||
if not isinstance(formsemestre_id, int):
|
||||
try:
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "formsemestre_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
if dept_id is not None:
|
||||
|
@ -193,7 +208,7 @@ class FormSemestre(db.Model):
|
|||
return cls.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
|
||||
def sort_key(self) -> tuple:
|
||||
"""clé pour tris par ordre alphabétique
|
||||
"""clé pour tris par ordre de date_debut, le plus ancien en tête
|
||||
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
|
||||
return (self.date_debut, self.semestre_id)
|
||||
|
||||
|
@ -209,12 +224,12 @@ class FormSemestre(db.Model):
|
|||
d["formsemestre_id"] = self.id
|
||||
d["titre_num"] = self.titre_num()
|
||||
if self.date_debut:
|
||||
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
|
||||
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
||||
d["date_debut_iso"] = self.date_debut.isoformat()
|
||||
else:
|
||||
d["date_debut"] = d["date_debut_iso"] = ""
|
||||
if self.date_fin:
|
||||
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
|
||||
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
||||
d["date_fin_iso"] = self.date_fin.isoformat()
|
||||
else:
|
||||
d["date_fin"] = d["date_fin_iso"] = ""
|
||||
|
@ -232,19 +247,20 @@ class FormSemestre(db.Model):
|
|||
|
||||
def to_dict_api(self):
|
||||
"""
|
||||
Un dict avec les informations sur le semestre destiné à l'api
|
||||
Un dict avec les informations sur le semestre destinées à l'api
|
||||
"""
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
d.pop("groups_auto_assignment_data", None)
|
||||
d["annee_scolaire"] = self.annee_scolaire()
|
||||
d["bul_hide_xml"] = self.bul_hide_xml
|
||||
if self.date_debut:
|
||||
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
|
||||
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
||||
d["date_debut_iso"] = self.date_debut.isoformat()
|
||||
else:
|
||||
d["date_debut"] = d["date_debut_iso"] = ""
|
||||
if self.date_fin:
|
||||
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
|
||||
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
||||
d["date_fin_iso"] = self.date_fin.isoformat()
|
||||
else:
|
||||
d["date_fin"] = d["date_fin_iso"] = ""
|
||||
|
@ -271,7 +287,10 @@ class FormSemestre(db.Model):
|
|||
raise ScoValueError("Le semestre n'a pas de groupe par défaut")
|
||||
|
||||
def get_edt_ids(self) -> list[str]:
|
||||
"l'ids pour l'emploi du temps: à défaut, les codes étape Apogée"
|
||||
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
|
||||
Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
|
||||
précisément l'accès au fichier ics.
|
||||
"""
|
||||
return (
|
||||
scu.split_id(self.edt_id)
|
||||
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
|
||||
|
@ -380,6 +399,80 @@ class FormSemestre(db.Model):
|
|||
_cache[key] = ues
|
||||
return ues
|
||||
|
||||
@classmethod
|
||||
def get_user_formsemestres_annee_by_dept(
|
||||
cls, user: User
|
||||
) -> tuple[
|
||||
defaultdict[int, list["FormSemestre"]], defaultdict[int, list[ModuleImpl]]
|
||||
]:
|
||||
"""Liste des formsemestres de l'année scolaire
|
||||
dans lesquels user intervient (comme resp., resp. de module ou enseignant),
|
||||
ainsi que la liste des modimpls concernés dans chaque formsemestre
|
||||
Attention: les semestres et modimpls peuvent être de différents départements !
|
||||
Résultat:
|
||||
{ dept_id : [ formsemestre, ... ] },
|
||||
{ formsemestre_id : [ modimpl, ... ]}
|
||||
"""
|
||||
debut_annee_scolaire = scu.date_debut_annee_scolaire()
|
||||
fin_annee_scolaire = scu.date_fin_annee_scolaire()
|
||||
|
||||
query = FormSemestre.query.filter(
|
||||
FormSemestre.date_fin >= debut_annee_scolaire,
|
||||
FormSemestre.date_debut < fin_annee_scolaire,
|
||||
)
|
||||
# responsable ?
|
||||
formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
|
||||
responsable_id=user.id
|
||||
)
|
||||
# Responsable d'un modimpl ?
|
||||
modimpls_resp = (
|
||||
ModuleImpl.query.filter_by(responsable_id=user.id)
|
||||
.join(FormSemestre)
|
||||
.filter(
|
||||
FormSemestre.date_fin >= debut_annee_scolaire,
|
||||
FormSemestre.date_debut < fin_annee_scolaire,
|
||||
)
|
||||
)
|
||||
# Enseignant dans un modimpl ?
|
||||
modimpls_ens = (
|
||||
ModuleImpl.query.join(notes_modules_enseignants)
|
||||
.filter_by(ens_id=user.id)
|
||||
.join(FormSemestre)
|
||||
.filter(
|
||||
FormSemestre.date_fin >= debut_annee_scolaire,
|
||||
FormSemestre.date_debut < fin_annee_scolaire,
|
||||
)
|
||||
)
|
||||
# Liste les modimpls, uniques
|
||||
modimpls = modimpls_resp.all()
|
||||
ids = {modimpl.id for modimpl in modimpls}
|
||||
for modimpl in modimpls_ens:
|
||||
if modimpl.id not in ids:
|
||||
modimpls.append(modimpl)
|
||||
ids.add(modimpl.id)
|
||||
# Liste les formsemestres et modimpls associés
|
||||
modimpls_by_formsemestre = defaultdict(lambda: [])
|
||||
formsemestres = formsemestres_resp.all()
|
||||
ids = {formsemestre.id for formsemestre in formsemestres}
|
||||
for modimpl in chain(modimpls_resp, modimpls_ens):
|
||||
if modimpl.formsemestre_id not in ids:
|
||||
formsemestres.append(modimpl.formsemestre)
|
||||
ids.add(modimpl.formsemestre_id)
|
||||
modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
|
||||
# Tris et organisation par département
|
||||
formsemestres_by_dept = defaultdict(lambda: [])
|
||||
formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
|
||||
for formsemestre in formsemestres:
|
||||
formsemestres_by_dept[formsemestre.dept_id].append(formsemestre)
|
||||
modimpls = modimpls_by_formsemestre[formsemestre.id]
|
||||
if formsemestre.formation.is_apc():
|
||||
key = lambda x: x.module.sort_key_apc()
|
||||
else:
|
||||
key = lambda x: x.module.sort_key()
|
||||
modimpls.sort(key=key)
|
||||
|
||||
return formsemestres_by_dept, modimpls_by_formsemestre
|
||||
|
||||
def get_evaluations(self) -> list[Evaluation]:
|
||||
"Liste de toutes les évaluations du semestre, triées par module/numero"
|
||||
return (
|
||||
|
@ -587,7 +680,7 @@ class FormSemestre(db.Model):
|
|||
) -> db.Query:
|
||||
"""Liste (query) ordonnée des formsemestres courants, c'est
|
||||
à dire contenant la date courant (si None, la date actuelle)"""
|
||||
date_courante = date_courante or db.func.now()
|
||||
date_courante = date_courante or db.func.current_date()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
|
@ -783,9 +876,9 @@ class FormSemestre(db.Model):
|
|||
descr_sem += " " + self.modalite
|
||||
return descr_sem
|
||||
|
||||
def get_abs_count(self, etudid):
|
||||
def get_abs_count(self, etudid) -> tuple[int, int, int]:
|
||||
"""Les comptes d'absences de cet étudiant dans ce semestre:
|
||||
tuple (nb abs, nb abs justifiées)
|
||||
tuple (nb abs non just, nb abs justifiées, nb abs total)
|
||||
Utilise un cache.
|
||||
"""
|
||||
from app.scodoc import sco_assiduites
|
||||
|
@ -843,12 +936,16 @@ class FormSemestre(db.Model):
|
|||
partitions += [p for p in self.partitions if p.partition_name is None]
|
||||
return partitions
|
||||
|
||||
@cached_property
|
||||
def etudids_actifs(self) -> set:
|
||||
"Set des etudids inscrits non démissionnaires et non défaillants"
|
||||
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
|
||||
def etudids_actifs(self) -> tuple[list[int], set[int]]:
|
||||
"""Liste les etudids inscrits (incluant DEM et DEF),
|
||||
qui ser al'index des dataframes de notes
|
||||
et donne l'ensemble des inscrits non DEM ni DEF.
|
||||
"""
|
||||
return [inscr.etudid for inscr in self.inscriptions], {
|
||||
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
|
||||
}
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def etuds_inscriptions(self) -> dict:
|
||||
"""Map { etudid : inscription } (incluant DEM et DEF)"""
|
||||
return {ins.etud.id: ins for ins in self.inscriptions}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -242,15 +242,20 @@ class GroupDescr(ScoDocModel):
|
|||
|
||||
def to_dict(self, with_partition=True) -> dict:
|
||||
"""as a dict, with or without partition"""
|
||||
if with_partition:
|
||||
partition_dict = self.partition.to_dict(with_groups=False)
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
if with_partition:
|
||||
d["partition"] = self.partition.to_dict(with_groups=False)
|
||||
d["partition"] = partition_dict
|
||||
return d
|
||||
|
||||
def get_edt_ids(self) -> list[str]:
|
||||
"les ids pour l'emploi du temps: à défaut, le nom scodoc du groupe"
|
||||
return scu.split_id(self.edt_id) or [self.group_name] or []
|
||||
"les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe"
|
||||
return [
|
||||
scu.normalize_edt_id(x)
|
||||
for x in scu.split_id(self.edt_id) or [self.group_name] or []
|
||||
]
|
||||
|
||||
def get_nb_inscrits(self) -> int:
|
||||
"""Nombre inscrits à ce group et au formsemestre.
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
"""ScoDoc models: moduleimpls
|
||||
"""
|
||||
import pandas as pd
|
||||
from flask import abort, g
|
||||
from flask_login import current_user
|
||||
from flask_sqlalchemy.query import Query
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app import db
|
||||
from app.auth.models import User
|
||||
from app.comp import df_cache
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import APO_CODE_STR_LEN, ScoDocModel
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.evaluations import Evaluation
|
||||
from app.models.modules import Module
|
||||
|
@ -17,7 +18,7 @@ from app.scodoc.sco_permissions import Permission
|
|||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ModuleImpl(db.Model):
|
||||
class ModuleImpl(ScoDocModel):
|
||||
"""Mise en oeuvre d'un module pour une annee/semestre"""
|
||||
|
||||
__tablename__ = "notes_moduleimpl"
|
||||
|
@ -36,7 +37,10 @@ class ModuleImpl(db.Model):
|
|||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
|
||||
responsable_id = db.Column(
|
||||
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
|
||||
)
|
||||
responsable = db.relationship("User", back_populates="modimpls")
|
||||
# formule de calcul moyenne:
|
||||
computation_expr = db.Column(db.Text())
|
||||
|
||||
|
@ -52,8 +56,8 @@ class ModuleImpl(db.Model):
|
|||
secondary="notes_modules_enseignants",
|
||||
lazy="dynamic",
|
||||
backref="moduleimpl",
|
||||
viewonly=True,
|
||||
)
|
||||
"enseignants du module (sans le responsable)"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
|
||||
|
@ -68,11 +72,10 @@ class ModuleImpl(db.Model):
|
|||
|
||||
def get_edt_ids(self) -> list[str]:
|
||||
"les ids pour l'emploi du temps: à défaut, les codes Apogée"
|
||||
return (
|
||||
scu.split_id(self.edt_id)
|
||||
or scu.split_id(self.code_apogee)
|
||||
or self.module.get_edt_ids()
|
||||
)
|
||||
return [
|
||||
scu.normalize_edt_id(x)
|
||||
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee)
|
||||
] or self.module.get_edt_ids()
|
||||
|
||||
def get_evaluations_poids(self) -> pd.DataFrame:
|
||||
"""Les poids des évaluations vers les UE (accès via cache)"""
|
||||
|
@ -84,6 +87,23 @@ class ModuleImpl(db.Model):
|
|||
df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids)
|
||||
return evaluations_poids
|
||||
|
||||
@classmethod
|
||||
def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl":
|
||||
"""ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
from app.models.formsemestre import FormSemestre
|
||||
|
||||
if not isinstance(moduleimpl_id, int):
|
||||
try:
|
||||
moduleimpl_id = int(moduleimpl_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "moduleimpl_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
query = cls.query.filter_by(id=moduleimpl_id)
|
||||
if dept_id is not None:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=dept_id)
|
||||
return query.first_or_404()
|
||||
|
||||
def invalidate_evaluations_poids(self):
|
||||
"""Invalide poids cachés"""
|
||||
df_cache.EvaluationsPoidsCache.delete(self.id)
|
||||
|
@ -171,7 +191,7 @@ class ModuleImpl(db.Model):
|
|||
return allow_ens and user.id in (ens.id for ens in self.enseignants)
|
||||
return True
|
||||
|
||||
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
|
||||
def can_change_responsable(self, user: User, raise_exc=False) -> bool:
|
||||
"""Check if user can modify module resp.
|
||||
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
|
||||
= Admin, et dir des etud. (si option l'y autorise)
|
||||
|
@ -192,6 +212,27 @@ class ModuleImpl(db.Model):
|
|||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
|
||||
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool:
|
||||
"""check if user can modify ens list (raise exception if not)"
|
||||
if user is None, current user.
|
||||
"""
|
||||
user = current_user if user is None else user
|
||||
if not self.formsemestre.etat:
|
||||
if raise_exc:
|
||||
raise ScoLockedSemError("Modification impossible: semestre verrouille")
|
||||
return False
|
||||
# -- check access
|
||||
# admin, resp. module ou resp. semestre
|
||||
if (
|
||||
user.id != self.responsable_id
|
||||
and not user.has_permission(Permission.EditFormSemestre)
|
||||
and user.id not in (u.id for u in self.formsemestre.responsables)
|
||||
):
|
||||
if raise_exc:
|
||||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def est_inscrit(self, etud: Identite) -> bool:
|
||||
"""
|
||||
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
"""ScoDoc 9 models : Modules
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
from app import db
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
|
||||
from app.models.but_refcomp import (
|
||||
ApcParcours,
|
||||
ApcReferentielCompetences,
|
||||
app_critiques_modules,
|
||||
parcours_modules,
|
||||
)
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class Module(db.Model):
|
||||
class Module(models.ScoDocModel):
|
||||
"""Module"""
|
||||
|
||||
__tablename__ = "notes_modules"
|
||||
|
@ -76,6 +83,55 @@ class Module(db.Model):
|
|||
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
|
||||
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
|
||||
|
||||
@classmethod
|
||||
def convert_dict_fields(cls, args: dict) -> dict:
|
||||
"""Convert fields in the given dict. No other side effect.
|
||||
returns: dict to store in model's db.
|
||||
"""
|
||||
# s'assure que ects etc est non ''
|
||||
fs_empty_stored_as_nulls = {
|
||||
"coefficient",
|
||||
"ects",
|
||||
"heures_cours",
|
||||
"heures_td",
|
||||
"heures_tp",
|
||||
}
|
||||
args_dict = {}
|
||||
for key, value in args.items():
|
||||
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
|
||||
if key in fs_empty_stored_as_nulls and value == "":
|
||||
value = None
|
||||
args_dict[key] = value
|
||||
|
||||
return args_dict
|
||||
|
||||
@classmethod
|
||||
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
|
||||
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
|
||||
Add 'id' to excluded."""
|
||||
# on ne peut pas affecter directement parcours
|
||||
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
|
||||
|
||||
@classmethod
|
||||
def create_from_dict(cls, data: dict) -> "Module":
|
||||
"""Create from given dict, add parcours"""
|
||||
mod = super().create_from_dict(data)
|
||||
for p in data.get("parcours", []) or []:
|
||||
if isinstance(p, ApcParcours):
|
||||
parcour: ApcParcours = p
|
||||
else:
|
||||
pid = int(p)
|
||||
query = ApcParcours.query.filter_by(id=pid)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(ApcReferentielCompetences).filter_by(
|
||||
dept_id=g.scodoc_dept_id
|
||||
)
|
||||
parcour: ApcParcours = query.first()
|
||||
if parcour is None:
|
||||
raise ScoValueError("Parcours invalide")
|
||||
mod.parcours.append(parcour)
|
||||
return mod
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this module."""
|
||||
mod = Module(
|
||||
|
@ -160,6 +216,10 @@ class Module(db.Model):
|
|||
"Identifiant du module à afficher : abbrev ou titre ou code"
|
||||
return self.abbrev or self.titre or self.code
|
||||
|
||||
def sort_key(self) -> tuple:
|
||||
"""Clé de tri pour formations classiques"""
|
||||
return self.numero or 0, self.code
|
||||
|
||||
def sort_key_apc(self) -> tuple:
|
||||
"""Clé de tri pour avoir
|
||||
présentation par type (res, sae), parcours, type, numéro
|
||||
|
@ -288,7 +348,10 @@ class Module(db.Model):
|
|||
|
||||
def get_edt_ids(self) -> list[str]:
|
||||
"les ids pour l'emploi du temps: à défaut, le 1er code Apogée"
|
||||
return scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or []
|
||||
return [
|
||||
scu.normalize_edt_id(x)
|
||||
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or []
|
||||
]
|
||||
|
||||
def get_parcours(self) -> list[ApcParcours]:
|
||||
"""Les parcours utilisant ce module.
|
||||
|
@ -303,6 +366,14 @@ class Module(db.Model):
|
|||
return []
|
||||
return self.parcours
|
||||
|
||||
def add_tag(self, tag: "NotesTag"):
|
||||
"""Add tag to module. Check if already has it."""
|
||||
if tag.id in {t.id for t in self.tags}:
|
||||
return
|
||||
self.tags.append(tag)
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
|
||||
class ModuleUECoef(db.Model):
|
||||
"""Coefficients des modules vers les UE (APC, BUT)
|
||||
|
@ -365,6 +436,19 @@ class NotesTag(db.Model):
|
|||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
title = db.Column(db.Text(), nullable=False)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
|
||||
"""Get tag, or create it if it doesn't yet exists.
|
||||
If dept_id unspecified, use current dept.
|
||||
"""
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
|
||||
if tag is None:
|
||||
tag = NotesTag(dept_id=dept_id, title=title)
|
||||
db.session.add(tag)
|
||||
db.session.flush()
|
||||
return tag
|
||||
|
||||
|
||||
# Association tag <-> module
|
||||
notes_modules_tags = db.Table(
|
||||
|
|
|
@ -5,6 +5,7 @@ from flask import g
|
|||
import pandas as pd
|
||||
|
||||
from app import db, log
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.but_refcomp import ApcNiveau, ApcParcours
|
||||
|
@ -12,7 +13,7 @@ from app.models.modules import Module
|
|||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class UniteEns(db.Model):
|
||||
class UniteEns(models.ScoDocModel):
|
||||
"""Unité d'Enseignement (UE)"""
|
||||
|
||||
__tablename__ = "notes_ue"
|
||||
|
@ -81,7 +82,7 @@ class UniteEns(db.Model):
|
|||
'EXTERNE' if self.is_external else ''})>"""
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this ue.
|
||||
"""Create a new copy of this ue, add to session.
|
||||
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
|
||||
(parcours et niveau).
|
||||
"""
|
||||
|
@ -100,8 +101,26 @@ class UniteEns(db.Model):
|
|||
coef_rcue=self.coef_rcue,
|
||||
color=self.color,
|
||||
)
|
||||
db.session.add(ue)
|
||||
return ue
|
||||
|
||||
@classmethod
|
||||
def convert_dict_fields(cls, args: dict) -> dict:
|
||||
"""Convert fields from the given dict to model's attributes values. No side effect.
|
||||
|
||||
args: dict with args in application.
|
||||
returns: dict to store in model's db.
|
||||
"""
|
||||
args = args.copy()
|
||||
if "type" in args:
|
||||
args["type"] = int(args["type"] or 0)
|
||||
if "is_external" in args:
|
||||
args["is_external"] = scu.to_bool(args["is_external"])
|
||||
if "ects" in args:
|
||||
args["ects"] = float(args["ects"])
|
||||
|
||||
return args
|
||||
|
||||
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
|
||||
"""as a dict, with the same conversions as in ScoDoc7.
|
||||
If convert_objects, convert all attributes to native types
|
||||
|
@ -390,6 +409,14 @@ class UniteEns(db.Model):
|
|||
Renvoie (True, "") si ok, sinon (False, error_message)
|
||||
"""
|
||||
msg = ""
|
||||
# Safety check
|
||||
if self.formation.referentiel_competence is None:
|
||||
return False, "pas de référentiel de compétence"
|
||||
# Si tous les parcours, aucun (tronc commun)
|
||||
if {p.id for p in parcours} == {
|
||||
p.id for p in self.formation.referentiel_competence.parcours
|
||||
}:
|
||||
parcours = []
|
||||
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
|
||||
prev_niveau = self.niveau_competence
|
||||
if (
|
||||
|
@ -405,6 +432,7 @@ class UniteEns(db.Model):
|
|||
self.niveau_competence, parcours
|
||||
)
|
||||
if not ok:
|
||||
self.formation.invalidate_cached_sems()
|
||||
self.niveau_competence = prev_niveau # restore
|
||||
return False, error_message
|
||||
|
||||
|
|
|
@ -72,7 +72,7 @@ class ScolarFormSemestreValidation(db.Model):
|
|||
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
|
||||
} ({self.ue_id}): {self.code}"""
|
||||
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
|
||||
self.event_date.strftime("%d/%m/%Y")}"""
|
||||
self.event_date.strftime(scu.DATE_FMT)}"""
|
||||
|
||||
def delete(self):
|
||||
"Efface cette validation"
|
||||
|
@ -113,20 +113,20 @@ class ScolarFormSemestreValidation(db.Model):
|
|||
if self.ue.parcours else ""}
|
||||
{("émise par " + link)}
|
||||
: <b>{self.code}</b>{moyenne}
|
||||
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
|
||||
"""
|
||||
le {self.event_date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
else:
|
||||
return f"""Validation du semestre S{
|
||||
self.formsemestre.semestre_id if self.formsemestre else "?"}
|
||||
{self.formsemestre.html_link_status() if self.formsemestre else ""}
|
||||
: <b>{self.code}</b>
|
||||
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
|
||||
le {self.event_date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
|
||||
def ects(self) -> float:
|
||||
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
|
||||
return (
|
||||
self.ue.ects
|
||||
self.ue.ects or 0.0
|
||||
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
|
||||
else 0.0
|
||||
)
|
||||
|
@ -175,8 +175,8 @@ class ScolarAutorisationInscription(db.Model):
|
|||
)
|
||||
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
|
||||
{link}
|
||||
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
|
||||
"""
|
||||
le {self.date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def autorise_etud(
|
||||
|
|
|
@ -0,0 +1,342 @@
|
|||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.models import Identite
|
||||
from app.pe import pe_affichage
|
||||
from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag
|
||||
from app.pe.rcss import pe_rcs
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class InterClassTag(pe_tabletags.TableTag):
|
||||
"""
|
||||
Interclasse l'ensemble des étudiants diplômés à une année
|
||||
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit
|
||||
de type SemX ou RCSemX,
|
||||
en reportant les moyennes obtenues sur à la version tagguée
|
||||
du RCS (de type SxTag ou RCSTag).
|
||||
Sont ensuite calculés les classements (uniquement)
|
||||
sur les étudiants diplômes.
|
||||
|
||||
Args:
|
||||
nom_rcs: Le nom de l'aggrégat
|
||||
type_interclassement: Le type d'interclassement (par UE ou par compétences)
|
||||
etudiants_diplomes: L'identité des étudiants diplômés
|
||||
rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit
|
||||
les SemX soit les RCSemX recencés par le jury PE
|
||||
rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant
|
||||
soit les SxTag (associés aux SemX)
|
||||
soit les RCSTags (associés au RCSemX) calculés par le jury PE
|
||||
suivis: Un dictionnaire associé à chaque étudiant son rcss
|
||||
(de la forme ``{etudid: {nom_rcs: RCS_suivi}}``)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nom_rcs: str,
|
||||
type_interclassement: str,
|
||||
etudiants_diplomes: dict[int, Identite],
|
||||
rcss: dict[(str, int) : pe_rcs.RCS],
|
||||
rcstags: dict[(str, int) : pe_tabletags.TableTag],
|
||||
suivis: dict[int:dict],
|
||||
):
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
self.nom_rcs: str = nom_rcs
|
||||
"""Le nom du RCS interclassé"""
|
||||
|
||||
# Le type d'interclassement
|
||||
self.type = type_interclassement
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}"
|
||||
)
|
||||
|
||||
# Les informations sur les étudiants diplômés
|
||||
self.etuds: list[Identite] = list(etudiants_diplomes.values())
|
||||
"""Identités des étudiants diplômés"""
|
||||
self.add_etuds(self.etuds)
|
||||
|
||||
self.diplomes_ids = set(etudiants_diplomes.keys())
|
||||
"""Etudids des étudiants diplômés"""
|
||||
|
||||
# Les RCS de l'aggrégat (SemX ou RCSemX)
|
||||
self.rcss: dict[(str, int), pe_rcs.RCS] = {}
|
||||
"""Ensemble des SemX ou des RCSemX associés à l'aggrégat"""
|
||||
for (nom, fid), rcs in rcss.items():
|
||||
if nom == nom_rcs:
|
||||
self.rcss[(nom, fid)] = rcss
|
||||
|
||||
# Les données tagguées
|
||||
self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {}
|
||||
"""Ensemble des SxTag ou des RCSTags associés à l'aggrégat"""
|
||||
for rcs_id in self.rcss:
|
||||
self.rcstags[rcs_id] = rcstags[rcs_id]
|
||||
|
||||
# Les RCS (SemX ou RCSemX) suivis par les étudiants du jury,
|
||||
# en ne gardant que ceux associés aux diplomés
|
||||
self.suivis: dict[int, pe_rcs.RCS] = {}
|
||||
"""Association entre chaque étudiant et le SxTag ou RCSTag à prendre
|
||||
pour l'aggrégat"""
|
||||
for etudid in self.diplomes_ids:
|
||||
self.suivis[etudid] = suivis[etudid][nom_rcs]
|
||||
|
||||
# Les données sur les tags
|
||||
self.tags_sorted = self._do_taglist()
|
||||
"""Liste des tags (triés par ordre alphabétique)"""
|
||||
aff = pe_affichage.repr_tags(self.tags_sorted)
|
||||
pe_affichage.pe_print(f"--> Tags : {aff}")
|
||||
|
||||
# Les données sur les UEs (si SxTag) ou compétences (si RCSTag)
|
||||
self.champs_sorted = self._do_ues_ou_competences_list()
|
||||
"""Les champs (UEs ou compétences) de l'interclassement"""
|
||||
if self.type == pe_moytag.CODE_MOY_UE:
|
||||
pe_affichage.pe_print(
|
||||
f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(
|
||||
f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}"
|
||||
)
|
||||
|
||||
# Etudids triés
|
||||
self.etudids_sorted = sorted(list(self.diplomes_ids))
|
||||
|
||||
self.nom = self.get_repr()
|
||||
"""Représentation textuelle de l'interclassement"""
|
||||
|
||||
# Synthétise les moyennes/classements par tag
|
||||
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
|
||||
for tag in self.tags_sorted:
|
||||
# Les moyennes tous modules confondus
|
||||
notes_gen = self.compute_notes_matrice(tag)
|
||||
|
||||
# Les coefficients de la moyenne générale
|
||||
coeffs = self.compute_coeffs_matrice(tag)
|
||||
aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True)
|
||||
pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ")
|
||||
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
self.type,
|
||||
notes_gen,
|
||||
coeffs, # limite les moyennes aux étudiants de la promo
|
||||
)
|
||||
|
||||
def get_repr(self) -> str:
|
||||
"""Une représentation textuelle"""
|
||||
return f"{self.nom_rcs} par {self.type}"
|
||||
|
||||
def _do_taglist(self):
|
||||
"""Synthétise les tags à partir des TableTags (SXTag ou RCSTag)
|
||||
|
||||
Returns:
|
||||
Une liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for rcstag in self.rcstags.values():
|
||||
tags.extend(rcstag.tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
def compute_notes_matrice(self, tag) -> pd.DataFrame:
|
||||
"""Construit la matrice de notes (etudids x champs) en
|
||||
reportant les moyennes obtenues par les étudiants
|
||||
aux semestres de l'aggrégat pour le tag visé.
|
||||
|
||||
Les champs peuvent être des acronymes d'UEs ou des compétences.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
# etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
|
||||
|
||||
for rcstag in self.rcstags.values():
|
||||
# Charge les moyennes au tag d'un RCStag
|
||||
if tag in rcstag.moyennes_tags:
|
||||
moytag = rcstag.moyennes_tags[tag]
|
||||
|
||||
notes = moytag.matrice_notes_gen # dataframe etudids x ues
|
||||
|
||||
# Etudiants/Champs communs entre le RCSTag et les données interclassées
|
||||
(
|
||||
etudids_communs,
|
||||
champs_communs,
|
||||
) = pe_comp.find_index_and_columns_communs(df, notes)
|
||||
|
||||
# Injecte les notes par tag
|
||||
df.loc[etudids_communs, champs_communs] = notes.loc[
|
||||
etudids_communs, champs_communs
|
||||
]
|
||||
|
||||
return df
|
||||
|
||||
def compute_coeffs_matrice(self, tag) -> pd.DataFrame:
|
||||
"""Idem que compute_notes_matrices mais pour les coeffs
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
# etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
|
||||
|
||||
for rcstag in self.rcstags.values():
|
||||
if tag in rcstag.moyennes_tags:
|
||||
# Charge les coeffs au tag d'un RCStag
|
||||
coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs_moy_gen
|
||||
|
||||
# Etudiants/Champs communs entre le RCSTag et les données interclassées
|
||||
(
|
||||
etudids_communs,
|
||||
champs_communs,
|
||||
) = pe_comp.find_index_and_columns_communs(df, coeffs)
|
||||
|
||||
# Injecte les coeffs par tag
|
||||
df.loc[etudids_communs, champs_communs] = coeffs.loc[
|
||||
etudids_communs, champs_communs
|
||||
]
|
||||
|
||||
return df
|
||||
|
||||
def _do_ues_ou_competences_list(self) -> list[str]:
|
||||
"""Synthétise les champs (UEs ou compétences) sur lesquels
|
||||
sont calculés les moyennes.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {'acronyme_ue' : 'compétences'}
|
||||
"""
|
||||
dict_champs = []
|
||||
for rcstag in self.rcstags.values():
|
||||
if isinstance(rcstag, pe_sxtag.SxTag):
|
||||
champs = rcstag.acronymes_sorted
|
||||
else: # pe_rcstag.RCSTag
|
||||
champs = rcstag.competences_sorted
|
||||
dict_champs.extend(champs)
|
||||
return sorted(set(dict_champs))
|
||||
|
||||
def has_tags(self):
|
||||
"""Indique si l'interclassement a des tags (cas d'un
|
||||
interclassement sur un S5 qui n'a pas eu lieu)
|
||||
"""
|
||||
return len(self.tags_sorted) > 0
|
||||
|
||||
def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]):
|
||||
"""Renvoie un rcstag significatif (ayant des tags et des notes aux tags)
|
||||
parmi le dictionnaire de rcsstags"""
|
||||
for rcstag_id, rcstag in rcsstags.items():
|
||||
moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags
|
||||
for tag, moystag in moystags.items():
|
||||
tags_tries = moystag.get_all_significant_tags()
|
||||
if tags_tries:
|
||||
return moystag
|
||||
return None
|
||||
|
||||
def compute_df_synthese_moyennes_tag(
|
||||
self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True}
|
||||
) -> pd.DataFrame:
|
||||
"""Construit le dataframe retraçant pour les données des moyennes
|
||||
pour affichage dans la synthèse du jury PE. (cf. to_df())
|
||||
|
||||
Args:
|
||||
etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
if aggregat:
|
||||
assert (
|
||||
aggregat == self.nom_rcs
|
||||
), "L'interclassement ciblé ne correspond pas à l'aggrégat visé"
|
||||
|
||||
etudids_sorted = sorted(list(self.diplomes_ids))
|
||||
|
||||
if not self.rcstags:
|
||||
return None
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
initialisation = False
|
||||
df = pd.DataFrame()
|
||||
|
||||
# Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi
|
||||
asso_rcs_etudids = {}
|
||||
for etudid in etudids_sorted:
|
||||
rcs = self.suivis[etudid]
|
||||
if rcs:
|
||||
if rcs.rcs_id not in asso_rcs_etudids:
|
||||
asso_rcs_etudids[rcs.rcs_id] = []
|
||||
asso_rcs_etudids[rcs.rcs_id].append(etudid)
|
||||
|
||||
for rcs_id, etudids in asso_rcs_etudids.items():
|
||||
# Charge ses moyennes au RCSTag suivi
|
||||
rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag
|
||||
# Charge la moyenne
|
||||
if tag in rcstag.moyennes_tags:
|
||||
moytag: pd.DataFrame = rcstag.moyennes_tags[tag]
|
||||
df_moytag = moytag.to_df(
|
||||
aggregat=aggregat, cohorte="Groupe", options=options
|
||||
)
|
||||
|
||||
# Modif les colonnes au regard du 1er df_moytag significatif lu
|
||||
if not initialisation:
|
||||
df = pd.DataFrame(
|
||||
np.nan, index=etudids_sorted, columns=df_moytag.columns
|
||||
)
|
||||
colonnes = list(df_moytag.columns)
|
||||
for col in colonnes:
|
||||
if col.endswith("rang"):
|
||||
df[col] = df[col].astype(str)
|
||||
initialisation = True
|
||||
|
||||
# Injecte les notes des étudiants
|
||||
df.loc[etudids, :] = df_moytag.loc[etudids, :]
|
||||
|
||||
return df
|
|
@ -0,0 +1,128 @@
|
|||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.comp.moy_sem import comp_ranks_series
|
||||
from app.pe import pe_affichage
|
||||
|
||||
|
||||
class Moyenne:
|
||||
COLONNES = [
|
||||
"note",
|
||||
"classement",
|
||||
"rang",
|
||||
"min",
|
||||
"max",
|
||||
"moy",
|
||||
"nb_etuds",
|
||||
"nb_inscrits",
|
||||
]
|
||||
"""Colonnes du df"""
|
||||
|
||||
@classmethod
|
||||
def get_colonnes_synthese(cls, with_min_max_moy):
|
||||
if with_min_max_moy:
|
||||
return ["note", "rang", "min", "max", "moy"]
|
||||
else:
|
||||
return ["note", "rang"]
|
||||
|
||||
def __init__(self, notes: pd.Series):
|
||||
"""Classe centralisant la synthèse des moyennes/classements d'une série
|
||||
de notes :
|
||||
|
||||
* des "notes" : la Serie pandas des notes (float),
|
||||
* des "classements" : la Serie pandas des classements (float),
|
||||
* des "min" : la note minimum,
|
||||
* des "max" : la note maximum,
|
||||
* des "moy" : la moyenne,
|
||||
* des "nb_inscrits" : le nombre d'étudiants ayant une note,
|
||||
"""
|
||||
self.notes = notes
|
||||
"""Les notes"""
|
||||
self.etudids = list(notes.index) # calcul à venir
|
||||
"""Les id des étudiants"""
|
||||
self.inscrits_ids = notes[notes.notnull()].index.to_list()
|
||||
"""Les id des étudiants dont la note est non nulle"""
|
||||
self.df: pd.DataFrame = self.comp_moy_et_stat(self.notes)
|
||||
"""Le dataframe retraçant les moyennes/classements/statistiques"""
|
||||
self.synthese = self.to_dict()
|
||||
"""La synthèse (dictionnaire) des notes/classements/statistiques"""
|
||||
|
||||
def comp_moy_et_stat(self, notes: pd.Series) -> dict:
|
||||
"""Calcule et structure les données nécessaires au PE pour une série
|
||||
de notes (pouvant être une moyenne d'un tag à une UE ou une moyenne générale
|
||||
d'un tag) dans un dictionnaire spécifique.
|
||||
|
||||
Partant des notes, sont calculés les classements (en ne tenant compte
|
||||
que des notes non nulles).
|
||||
|
||||
Args:
|
||||
notes: Une série de notes (avec des éventuels NaN)
|
||||
|
||||
Returns:
|
||||
Un dictionnaire stockant les notes, les classements, le min,
|
||||
le max, la moyenne, le nb de notes (donc d'inscrits)
|
||||
"""
|
||||
df = pd.DataFrame(
|
||||
np.nan,
|
||||
index=self.etudids,
|
||||
columns=Moyenne.COLONNES,
|
||||
)
|
||||
|
||||
# Supprime d'éventuelles chaines de caractères dans les notes
|
||||
notes = pd.to_numeric(notes, errors="coerce")
|
||||
df["note"] = notes
|
||||
|
||||
# Les nb d'étudiants & nb d'inscrits
|
||||
df["nb_etuds"] = len(self.etudids)
|
||||
df["nb_etuds"] = df["nb_etuds"].astype(int)
|
||||
|
||||
# Les étudiants dont la note n'est pas nulle
|
||||
inscrits_ids = notes[notes.notnull()].index.to_list()
|
||||
df.loc[inscrits_ids, "nb_inscrits"] = len(inscrits_ids)
|
||||
# df["nb_inscrits"] = df["nb_inscrits"].astype(int)
|
||||
|
||||
# Le classement des inscrits
|
||||
notes_non_nulles = notes[inscrits_ids]
|
||||
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
|
||||
df.loc[inscrits_ids, "classement"] = class_int
|
||||
# df["classement"] = df["classement"].astype(int)
|
||||
|
||||
# Le rang (classement/nb_inscrit)
|
||||
df["rang"] = df["rang"].astype(str)
|
||||
df.loc[inscrits_ids, "rang"] = (
|
||||
df.loc[inscrits_ids, "classement"].astype(int).astype(str)
|
||||
+ "/"
|
||||
+ df.loc[inscrits_ids, "nb_inscrits"].astype(int).astype(str)
|
||||
)
|
||||
|
||||
# Les stat (des inscrits)
|
||||
df.loc[inscrits_ids, "min"] = notes.min()
|
||||
df.loc[inscrits_ids, "max"] = notes.max()
|
||||
df.loc[inscrits_ids, "moy"] = notes.mean()
|
||||
|
||||
return df
|
||||
|
||||
def get_df_synthese(self, with_min_max_moy=None):
|
||||
"""Renvoie le df de synthese limité aux colonnes de synthese"""
|
||||
colonnes_synthese = Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
df = self.df[colonnes_synthese].copy()
|
||||
df["rang"] = df["rang"].replace("nan", "")
|
||||
return df
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques générale (but)"""
|
||||
synthese = {
|
||||
"notes": self.df["note"],
|
||||
"classements": self.df["classement"],
|
||||
"min": self.df["min"].mean(),
|
||||
"max": self.df["max"].mean(),
|
||||
"moy": self.df["moy"].mean(),
|
||||
"nb_inscrits": self.df["nb_inscrits"].mean(),
|
||||
}
|
||||
return synthese
|
||||
|
||||
def is_significatif(self) -> bool:
|
||||
"""Indique si la moyenne est significative (c'est-à-dire à des notes)"""
|
||||
return self.synthese["nb_inscrits"] > 0
|
|
@ -0,0 +1,169 @@
|
|||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app import comp
|
||||
from app.comp.moy_sem import comp_ranks_series
|
||||
from app.pe.moys import pe_moy
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
CODE_MOY_UE = "UEs"
|
||||
CODE_MOY_COMPETENCES = "Compétences"
|
||||
CHAMP_GENERAL = "Général" # Nom du champ de la moyenne générale
|
||||
|
||||
|
||||
class MoyennesTag:
|
||||
def __init__(
|
||||
self,
|
||||
tag: str,
|
||||
type_moyenne: str,
|
||||
matrice_notes_gen: pd.DataFrame, # etudids x colonnes
|
||||
matrice_coeffs: pd.DataFrame, # etudids x colonnes
|
||||
):
|
||||
"""Classe centralisant la synthèse des moyennes/classements d'une série
|
||||
d'étudiants à un tag donné, en différenciant les notes
|
||||
obtenues aux UE et au général (toutes UEs confondues)
|
||||
|
||||
|
||||
Args:
|
||||
tag: Un tag
|
||||
matrice_notes_gen: Les moyennes (etudid x acronymes_ues ou etudid x compétences)
|
||||
aux différentes UEs ou compétences
|
||||
# notes_gen: Une série de notes (moyenne) sous forme d'un ``pd.Series`` (toutes UEs confondues)
|
||||
"""
|
||||
self.tag = tag
|
||||
"""Le tag associé aux moyennes"""
|
||||
|
||||
self.type = type_moyenne
|
||||
"""Le type de moyennes (par UEs ou par compétences)"""
|
||||
|
||||
# Les moyennes par UE/compétences (ressources/SAEs confondues)
|
||||
self.matrice_notes_gen: pd.DataFrame = matrice_notes_gen
|
||||
"""Les notes par UEs ou Compétences (DataFrame)"""
|
||||
|
||||
self.matrice_coeffs_moy_gen: pd.DataFrame = matrice_coeffs
|
||||
"""Les coeffs à appliquer pour le calcul des moyennes générales
|
||||
(toutes UE ou compétences confondues). NaN si étudiant non inscrit"""
|
||||
|
||||
self.moyennes_gen: dict[int, pd.DataFrame] = {}
|
||||
"""Dataframes retraçant les moyennes/classements/statistiques des étudiants aux UEs"""
|
||||
|
||||
self.etudids = self.matrice_notes_gen.index
|
||||
"""Les étudids renseignés dans les moyennes"""
|
||||
|
||||
self.champs = self.matrice_notes_gen.columns
|
||||
"""Les champs (acronymes d'UE ou compétences) renseignés dans les moyennes"""
|
||||
for col in self.champs: # if ue.type != UE_SPORT:
|
||||
# Les moyennes tous modules confondus
|
||||
notes = matrice_notes_gen[col]
|
||||
self.moyennes_gen[col] = pe_moy.Moyenne(notes)
|
||||
|
||||
# Les moyennes générales (toutes UEs confondues)
|
||||
self.notes_gen = pd.Series(np.nan, index=self.matrice_notes_gen.index)
|
||||
if self.has_notes():
|
||||
self.notes_gen = self.compute_moy_gen(
|
||||
self.matrice_notes_gen, self.matrice_coeffs_moy_gen
|
||||
)
|
||||
self.moyenne_gen = pe_moy.Moyenne(self.notes_gen)
|
||||
"""Dataframe retraçant les moyennes/classements/statistiques général (toutes UESs confondues et modules confondus)"""
|
||||
|
||||
def has_notes(self):
|
||||
"""Détermine si les moyennes (aux UEs ou aux compétences)
|
||||
ont des notes
|
||||
|
||||
Returns:
|
||||
True si la moytag a des notes, False sinon
|
||||
"""
|
||||
notes = self.matrice_notes_gen
|
||||
|
||||
nbre_nan = notes.isna().sum().sum()
|
||||
nbre_notes_potentielles = len(notes.index) * len(notes.columns)
|
||||
if nbre_nan == nbre_notes_potentielles:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def compute_moy_gen(self, moys: pd.DataFrame, coeffs: pd.DataFrame) -> pd.Series:
|
||||
"""Calcule la moyenne générale (toutes UE/compétences confondus)
|
||||
pour le tag considéré, en pondérant les notes obtenues au UE
|
||||
par les coeff (généralement les crédits ECTS).
|
||||
|
||||
Args:
|
||||
moys: Les moyennes etudids x acronymes_ues/compétences
|
||||
coeff: Les coeff etudids x ueids/compétences
|
||||
"""
|
||||
|
||||
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
|
||||
try:
|
||||
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
|
||||
moys,
|
||||
coeffs.fillna(0.0),
|
||||
# formation_id=self.formsemestre.formation_id,
|
||||
skip_empty_ues=True,
|
||||
)
|
||||
except TypeError as e:
|
||||
raise TypeError(
|
||||
"Pb dans le calcul de la moyenne toutes UEs/compétences confondues"
|
||||
)
|
||||
|
||||
return moy_gen_tag
|
||||
|
||||
def to_df(
|
||||
self, aggregat=None, cohorte=None, options={"min_max_moy": True}
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie le df synthétisant l'ensemble des données
|
||||
connues
|
||||
Adapte les intitulés des colonnes aux données fournies
|
||||
(nom d'aggrégat, type de cohorte).
|
||||
"""
|
||||
if "min_max_moy" not in options or options["min_max_moy"]:
|
||||
with_min_max_moy = True
|
||||
else:
|
||||
with_min_max_moy = False
|
||||
|
||||
etudids_sorted = sorted(self.etudids)
|
||||
|
||||
df = pd.DataFrame(index=etudids_sorted)
|
||||
|
||||
# Ajout des notes pour tous les champs
|
||||
champs = list(self.champs)
|
||||
for champ in champs:
|
||||
df_champ = self.moyennes_gen[champ].get_df_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
) # le dataframe
|
||||
# Renomme les colonnes
|
||||
|
||||
cols = [
|
||||
get_colonne_df(aggregat, self.tag, champ, cohorte, critere)
|
||||
for critere in pe_moy.Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
]
|
||||
df_champ.columns = cols
|
||||
df = df.join(df_champ)
|
||||
|
||||
# Ajoute la moy générale
|
||||
df_moy_gen = self.moyenne_gen.get_df_synthese(with_min_max_moy=with_min_max_moy)
|
||||
cols = [
|
||||
get_colonne_df(aggregat, self.tag, CHAMP_GENERAL, cohorte, critere)
|
||||
for critere in pe_moy.Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
]
|
||||
df_moy_gen.columns = cols
|
||||
df = df.join(df_moy_gen)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def get_colonne_df(aggregat, tag, champ, cohorte, critere):
|
||||
"""Renvoie le tuple (aggregat, tag, champ, cohorte, critere)
|
||||
utilisé pour désigner les colonnes du df"""
|
||||
liste_champs = []
|
||||
if aggregat != None:
|
||||
liste_champs += [aggregat]
|
||||
|
||||
liste_champs += [tag, champ]
|
||||
if cohorte != None:
|
||||
liste_champs += [cohorte]
|
||||
liste_champs += [critere]
|
||||
return "|".join(liste_champs)
|
|
@ -0,0 +1,466 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.models import FormSemestre
|
||||
from app.pe import pe_affichage
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from app.pe.rcss import pe_rcs, pe_rcsemx
|
||||
import app.pe.moys.pe_sxtag as pe_sxtag
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.pe.moys import pe_tabletags, pe_moytag
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class RCSemXTag(pe_tabletags.TableTag):
|
||||
def __init__(
|
||||
self,
|
||||
rcsemx: pe_rcsemx.RCSemX,
|
||||
sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
semXs_suivis: dict[int, dict],
|
||||
):
|
||||
"""Calcule les moyennes par tag (orientées compétences)
|
||||
d'un regroupement de SxTag
|
||||
(RCRCF), pour extraire les classements par tag pour un
|
||||
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
|
||||
participé au même semestre terminal.
|
||||
|
||||
Args:
|
||||
rcsemx: Le RCSemX (identifié par un nom et l'id de son semestre terminal)
|
||||
sxstags: Les données sur les SemX taggués
|
||||
semXs_suivis: Les données indiquant quels SXTags sont à prendre en compte
|
||||
pour chaque étudiant
|
||||
"""
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
self.rcs_id: tuple(str, int) = rcsemx.rcs_id
|
||||
"""Identifiant du RCSemXTag (identique au RCSemX sur lequel il s'appuie)"""
|
||||
|
||||
self.rcsemx: pe_rcsemx.RCSemX = rcsemx
|
||||
"""Le regroupement RCSemX associé au RCSemXTag"""
|
||||
|
||||
self.semXs_suivis = semXs_suivis
|
||||
"""Les semXs suivis par les étudiants"""
|
||||
|
||||
self.nom = self.get_repr()
|
||||
"""Représentation textuelle du RSCtag"""
|
||||
|
||||
# Les données du semestre final
|
||||
self.formsemestre_final: FormSemestre = rcsemx.formsemestre_final
|
||||
"""Le semestre final"""
|
||||
self.fid_final: int = rcsemx.formsemestre_final.formsemestre_id
|
||||
"""Le fid du semestre final"""
|
||||
|
||||
# Affichage pour debug
|
||||
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
|
||||
|
||||
# Les données aggrégés (RCRCF + SxTags)
|
||||
self.semXs_aggreges: dict[(str, int) : pe_rcsemx.RCSemX] = rcsemx.semXs_aggreges
|
||||
"""Les SemX aggrégés"""
|
||||
self.sxstags_aggreges = {}
|
||||
"""Les SxTag associés aux SemX aggrégés"""
|
||||
try:
|
||||
for rcf_id in self.semXs_aggreges:
|
||||
self.sxstags_aggreges[rcf_id] = sxstags[rcf_id]
|
||||
except:
|
||||
raise ValueError("Semestres SxTag manquants")
|
||||
self.sxtags_connus = sxstags # Tous les sxstags connus
|
||||
|
||||
# Les étudiants (etuds, états civils & etudis)
|
||||
sems_dans_aggregat = rcsemx.aggregat
|
||||
sxtag_final = self.sxstags_aggreges[(sems_dans_aggregat[-1], self.rcs_id[1])]
|
||||
self.etuds = sxtag_final.etuds
|
||||
"""Les étudiants (extraits du semestre final)"""
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les étudids triés"""
|
||||
|
||||
# Les compétences (extraites de tous les Sxtags)
|
||||
self.acronymes_ues_to_competences = self._do_acronymes_to_competences()
|
||||
"""L'association acronyme d'UEs -> compétence (extraites des SxTag aggrégés)"""
|
||||
|
||||
self.competences_sorted = sorted(
|
||||
set(self.acronymes_ues_to_competences.values())
|
||||
)
|
||||
"""Compétences (triées par nom, extraites des SxTag aggrégés)"""
|
||||
aff = pe_affichage.repr_comp_et_ues(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> Compétences : {', '.join(self.competences_sorted)}")
|
||||
|
||||
# Les tags
|
||||
self.tags_sorted = self._do_taglist()
|
||||
"""Tags extraits de tous les SxTag aggrégés"""
|
||||
aff_tag = ["👜" + tag for tag in self.tags_sorted]
|
||||
pe_affichage.pe_print(f"--> Tags : {', '.join(aff_tag)}")
|
||||
|
||||
# Les moyennes
|
||||
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
|
||||
|
||||
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
|
||||
for tag in self.tags_sorted:
|
||||
pe_affichage.pe_print(f"--> Moyennes du tag 👜{tag}")
|
||||
|
||||
# Traitement des inscriptions aux semX(tags)
|
||||
# ******************************************
|
||||
# Cube d'inscription (etudids_sorted x compétences_sorted x sxstags)
|
||||
# indiquant quel sxtag est valide pour chaque étudiant
|
||||
inscr_df, inscr_cube = self.compute_inscriptions_comps_cube(tag)
|
||||
|
||||
# Traitement des notes
|
||||
# ********************
|
||||
# Cube de notes (etudids_sorted x compétences_sorted x sxstags)
|
||||
notes_df, notes_cube = self.compute_notes_comps_cube(tag)
|
||||
# Calcule les moyennes sous forme d'un dataframe en les "aggrégant"
|
||||
# compétence par compétence
|
||||
moys_competences = self.compute_notes_competences(notes_cube, inscr_cube)
|
||||
|
||||
# Traitement des coeffs pour la moyenne générale
|
||||
# ***********************************************
|
||||
# Df des coeffs sur tous les SxTags aggrégés
|
||||
coeffs_df, coeffs_cube = self.compute_coeffs_comps_cube(tag)
|
||||
|
||||
# Synthèse des coefficients à prendre en compte pour la moyenne générale
|
||||
matrice_coeffs_moy_gen = self.compute_coeffs_competences(
|
||||
coeffs_cube, inscr_cube, notes_cube
|
||||
)
|
||||
|
||||
# Affichage des coeffs
|
||||
aff = pe_affichage.repr_profil_coeffs(
|
||||
matrice_coeffs_moy_gen, with_index=True
|
||||
)
|
||||
pe_affichage.pe_print(f" > Moyenne calculée avec pour coeffs : {aff}")
|
||||
|
||||
# Mémorise les moyennes et les coeff associés
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_COMPETENCES,
|
||||
moys_competences,
|
||||
matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
|
||||
return self.rcs_id == other.sxtag_id
|
||||
|
||||
def get_repr(self, verbose=True) -> str:
|
||||
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
|
||||
est basée)"""
|
||||
if verbose:
|
||||
return f"{self.__class__.__name__} basé sur " + self.rcsemx.get_repr(
|
||||
verbose=verbose
|
||||
)
|
||||
else:
|
||||
return f"{self.__class__.__name__} {self.rcs_id}"
|
||||
|
||||
def compute_notes_comps_cube(self, tag):
|
||||
"""Pour un tag donné, construit le cube de notes (etudid x competences x SxTag)
|
||||
nécessaire au calcul des moyennes,
|
||||
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
notes_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
notes_df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
# Charge les notes du semestre tag (copie car changement de nom de colonnes à venir)
|
||||
if tag in sxtag.moyennes_tags: # si le tag est présent dans le semestre
|
||||
moys_tag = sxtag.moyennes_tags[tag]
|
||||
|
||||
notes = moys_tag.matrice_notes_gen.copy() # dataframe etudids x ues
|
||||
|
||||
# Traduction des acronymes d'UE en compétences
|
||||
acronymes_ues_columns = notes.columns
|
||||
acronymes_to_comps = [
|
||||
self.acronymes_ues_to_competences[acro]
|
||||
for acro in acronymes_ues_columns
|
||||
]
|
||||
notes.columns = acronymes_to_comps
|
||||
|
||||
# Les étudiants et les compétences communes
|
||||
(
|
||||
etudids_communs,
|
||||
comp_communes,
|
||||
) = pe_comp.find_index_and_columns_communs(notes_df, notes)
|
||||
|
||||
# Recopie des notes et des coeffs
|
||||
notes_df.loc[etudids_communs, comp_communes] = notes.loc[
|
||||
etudids_communs, comp_communes
|
||||
]
|
||||
|
||||
# Supprime tout ce qui n'est pas numérique
|
||||
# for col in notes_df.columns:
|
||||
# notes_df[col] = pd.to_numeric(notes_df[col], errors="coerce")
|
||||
|
||||
# Stocke les dfs
|
||||
notes_dfs[sxtag_id] = notes_df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
notes_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
notes_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
|
||||
|
||||
return notes_dfs, notes_etudids_x_comps_x_sxtag
|
||||
|
||||
def compute_coeffs_comps_cube(self, tag):
|
||||
"""Pour un tag donné, construit
|
||||
le cube de coeffs (etudid x competences x SxTag) (traduisant les inscriptions
|
||||
des étudiants aux UEs en fonction de leur parcours)
|
||||
qui s'applique aux différents SxTag
|
||||
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
|
||||
coeffs_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
coeffs_df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
if tag in sxtag.moyennes_tags:
|
||||
moys_tag = sxtag.moyennes_tags[tag]
|
||||
|
||||
# Charge les notes et les coeffs du semestre tag
|
||||
coeffs = moys_tag.matrice_coeffs_moy_gen.copy() # les coeffs
|
||||
|
||||
# Traduction des acronymes d'UE en compétences
|
||||
acronymes_ues_columns = coeffs.columns
|
||||
acronymes_to_comps = [
|
||||
self.acronymes_ues_to_competences[acro]
|
||||
for acro in acronymes_ues_columns
|
||||
]
|
||||
coeffs.columns = acronymes_to_comps
|
||||
|
||||
# Les étudiants et les compétences communes
|
||||
etudids_communs, comp_communes = pe_comp.find_index_and_columns_communs(
|
||||
coeffs_df, coeffs
|
||||
)
|
||||
|
||||
# Recopie des notes et des coeffs
|
||||
coeffs_df.loc[etudids_communs, comp_communes] = coeffs.loc[
|
||||
etudids_communs, comp_communes
|
||||
]
|
||||
|
||||
# Stocke les dfs
|
||||
coeffs_dfs[sxtag_id] = coeffs_df
|
||||
|
||||
"""Réunit les coeffs sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
coeffs_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
coeffs_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
|
||||
|
||||
return coeffs_dfs, coeffs_etudids_x_comps_x_sxtag
|
||||
|
||||
def compute_inscriptions_comps_cube(
|
||||
self,
|
||||
tag,
|
||||
):
|
||||
"""Pour un tag donné, construit
|
||||
le cube etudid x competences x SxTag traduisant quels sxtags est à prendre
|
||||
en compte pour chaque étudiant.
|
||||
Contient des 0 et des 1 pour indiquer la prise en compte.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
# Initialisation
|
||||
inscriptions_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
inscription_df = pd.DataFrame(
|
||||
0, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
|
||||
# Les étudiants dont les résultats au sxtag ont été calculés
|
||||
etudids_sxtag = sxtag.etudids_sorted
|
||||
|
||||
# Les étudiants communs
|
||||
etudids_communs = sorted(set(self.etudids_sorted) & set(etudids_sxtag))
|
||||
|
||||
# Acte l'inscription
|
||||
inscription_df.loc[etudids_communs, :] = 1
|
||||
|
||||
# Stocke les dfs
|
||||
inscriptions_dfs[sxtag_id] = inscription_df
|
||||
|
||||
"""Réunit les inscriptions sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
inscriptions_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
inscriptions_etudids_x_comps_x_sxtag = np.stack(
|
||||
sxtag_x_etudids_x_comps, axis=-1
|
||||
)
|
||||
|
||||
return inscriptions_dfs, inscriptions_etudids_x_comps_x_sxtag
|
||||
|
||||
def _do_taglist(self) -> list[str]:
|
||||
"""Synthétise les tags à partir des Sxtags aggrégés.
|
||||
|
||||
Returns:
|
||||
Liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for frmsem_id in self.sxstags_aggreges:
|
||||
tags.extend(self.sxstags_aggreges[frmsem_id].tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
def _do_acronymes_to_competences(self) -> dict[str:str]:
|
||||
"""Synthétise l'association complète {acronyme_ue: competences}
|
||||
extraite de toutes les données/associations des SxTags
|
||||
aggrégés.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {'acronyme_ue' : 'compétences'}
|
||||
"""
|
||||
dict_competences = {}
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
dict_competences |= sxtag.acronymes_ues_to_competences
|
||||
return dict_competences
|
||||
|
||||
def compute_notes_competences(self, set_cube: np.array, inscriptions: np.array):
|
||||
"""Calcule la moyenne par compétences (à un tag donné) sur plusieurs semestres (partant du set_cube).
|
||||
|
||||
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
|
||||
|
||||
*Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
|
||||
par aggrégat de plusieurs semestres.
|
||||
|
||||
Args:
|
||||
set_cube: notes moyennes aux compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des floats avec des NaN
|
||||
inscriptions: inscrptions aux compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des 0 et des 1
|
||||
Returns:
|
||||
Un DataFrame avec pour columns les moyennes par tags,
|
||||
et pour rows les etudid
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube)
|
||||
# competences_sorted: list (dim. 1 du cube)
|
||||
nb_etuds, nb_comps, nb_semestres = set_cube.shape
|
||||
# assert nb_etuds == len(etudids_sorted)
|
||||
# assert nb_comps == len(competences_sorted)
|
||||
|
||||
# Applique le masque d'inscriptions
|
||||
set_cube_significatif = set_cube * inscriptions
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube_significatif)
|
||||
|
||||
# Enlève les NaN du cube de notes pour les entrées manquantes
|
||||
set_cube_no_nan = np.nan_to_num(set_cube_significatif, nan=0.0)
|
||||
|
||||
# Les moyennes par tag
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
|
||||
|
||||
# Le dataFrame des notes moyennes
|
||||
etud_moy_tag_df = pd.DataFrame(
|
||||
etud_moy_tag,
|
||||
index=self.etudids_sorted, # les etudids
|
||||
columns=self.competences_sorted, # les competences
|
||||
)
|
||||
etud_moy_tag_df.fillna(np.nan)
|
||||
|
||||
return etud_moy_tag_df
|
||||
|
||||
def compute_coeffs_competences(
|
||||
self,
|
||||
coeff_cube: np.array,
|
||||
inscriptions: np.array,
|
||||
set_cube: np.array,
|
||||
):
|
||||
"""Calcule les coeffs à utiliser pour la moyenne générale (toutes compétences
|
||||
confondues), en fonction des inscriptions.
|
||||
|
||||
Args:
|
||||
coeffs_cube: coeffs impliqués dans la moyenne générale (semestres par semestres)
|
||||
inscriptions: inscriptions aux UES|Compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des 0 ou des 1
|
||||
set_cube: les notes
|
||||
|
||||
|
||||
Returns:
|
||||
Un DataFrame de coefficients (etudids_sorted x compétences_sorted)
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube)
|
||||
# competences_sorted: list (dim. 1 du cube)
|
||||
nb_etuds, nb_comps, nb_semestres = inscriptions.shape
|
||||
# assert nb_etuds == len(etudids_sorted)
|
||||
# assert nb_comps == len(competences_sorted)
|
||||
|
||||
# Applique le masque des inscriptions aux coeffs et aux notes
|
||||
coeffs_significatifs = coeff_cube * inscriptions
|
||||
|
||||
# Enlève les NaN du cube de notes pour les entrées manquantes
|
||||
coeffs_cube_no_nan = np.nan_to_num(coeffs_significatifs, nan=0.0)
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube)
|
||||
|
||||
# Retire les coefficients associés à des données sans notes
|
||||
coeffs_cube_no_nan = coeffs_cube_no_nan * mask
|
||||
|
||||
# Somme les coefficients (correspondant à des notes)
|
||||
coeff_tag = np.sum(coeffs_cube_no_nan, axis=2)
|
||||
|
||||
# Le dataFrame des coeffs
|
||||
coeffs_df = pd.DataFrame(
|
||||
coeff_tag, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
# Remet à Nan les coeffs à 0
|
||||
coeffs_df = coeffs_df.fillna(np.nan)
|
||||
|
||||
return coeffs_df
|
|
@ -0,0 +1,476 @@
|
|||
# -*- pole: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Generfal Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from app import ScoValueError
|
||||
from app import comp
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import FormSemestre, UniteEns
|
||||
import app.pe.pe_affichage as pe_affichage
|
||||
import app.pe.pe_etudiant as pe_etudiant
|
||||
from app.pe.moys import pe_tabletags, pe_moytag
|
||||
from app.scodoc import sco_tag_module
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.sco_utils import *
|
||||
|
||||
|
||||
class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag):
|
||||
"""
|
||||
Un ResSemBUTTag représente les résultats des étudiants à un semestre, en donnant
|
||||
accès aux moyennes par tag.
|
||||
Il s'appuie principalement sur un ResultatsSemestreBUT.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formsemestre: FormSemestre,
|
||||
options={"moyennes_tags": True, "moyennes_ue_res_sae": False},
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
formsemestre: le ``FormSemestre`` sur lequel il se base
|
||||
options: Un dictionnaire d'options
|
||||
"""
|
||||
ResultatsSemestreBUT.__init__(self, formsemestre)
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
# Le nom du res_semestre taggué
|
||||
self.nom = self.get_repr(verbose=True)
|
||||
|
||||
# Les étudiants (etuds, états civils & etudis) ajouté
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les etudids des étudiants du ResultatsSemestreBUT triés"""
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"*** ResSemBUTTag du {self.nom} => {len(self.etudids_sorted)} étudiants"
|
||||
)
|
||||
|
||||
# Les UEs (et les dispenses d'UE)
|
||||
self.ues_standards: list[UniteEns] = [
|
||||
ue for ue in self.ues if ue.type == sco_codes.UE_STANDARD
|
||||
]
|
||||
"""Liste des UEs standards du ResultatsSemestreBUT"""
|
||||
|
||||
# Les parcours des étudiants à ce semestre
|
||||
self.parcours = []
|
||||
"""Parcours auxquels sont inscrits les étudiants"""
|
||||
for etudid in self.etudids_sorted:
|
||||
parcour = self.formsemestre.etuds_inscriptions[etudid].parcour
|
||||
if parcour:
|
||||
self.parcours += [parcour.libelle]
|
||||
else:
|
||||
self.parcours += [None]
|
||||
|
||||
# Les UEs en fonction des parcours
|
||||
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
|
||||
"""Inscription des étudiants aux UEs des parcours"""
|
||||
|
||||
# Les acronymes des UEs
|
||||
self.ues_to_acronymes = {ue.id: ue.acronyme for ue in self.ues_standards}
|
||||
self.acronymes_sorted = sorted(self.ues_to_acronymes.values())
|
||||
"""Les acronymes de UE triés par ordre alphabétique"""
|
||||
|
||||
# Les compétences associées aux UEs (définies par les acronymes)
|
||||
self.acronymes_ues_to_competences = {}
|
||||
"""Association acronyme d'UEs -> compétence"""
|
||||
for ue in self.ues_standards:
|
||||
assert ue.niveau_competence, ScoValueError(
|
||||
"Des UEs ne sont pas rattachées à des compétences"
|
||||
)
|
||||
nom = ue.niveau_competence.competence.titre
|
||||
self.acronymes_ues_to_competences[ue.acronyme] = nom
|
||||
self.competences_sorted = sorted(
|
||||
list(set(self.acronymes_ues_to_competences.values()))
|
||||
)
|
||||
"""Compétences triées par nom"""
|
||||
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
|
||||
|
||||
# Les tags personnalisés et auto:
|
||||
if "moyennes_tags" in options:
|
||||
tags_dict = self._get_tags_dict(avec_moyennes_tags=options["moyennes_tags"])
|
||||
else:
|
||||
tags_dict = self._get_tags_dict()
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"""--> {pe_affichage.aff_tags_par_categories(tags_dict)}"""
|
||||
)
|
||||
self._check_tags(tags_dict)
|
||||
|
||||
# Les coefficients pour le calcul de la moyenne générale, donnés par
|
||||
# acronymes d'UE
|
||||
self.matrice_coeffs_moy_gen = self._get_matrice_coeffs(
|
||||
self.ues_inscr_parcours_df, self.ues_standards
|
||||
)
|
||||
"""DataFrame indiquant les coeffs des UEs par ordre alphabétique d'acronyme"""
|
||||
profils_aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {profils_aff}"
|
||||
)
|
||||
|
||||
# Les capitalisations (mask etuids x acronyme_ue valant True si capitalisée, False sinon)
|
||||
self.capitalisations = self._get_capitalisations(self.ues_standards)
|
||||
"""DataFrame indiquant les UEs capitalisables d'un étudiant (etudids x )"""
|
||||
|
||||
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
|
||||
self.moyennes_tags = {}
|
||||
"""Moyennes par tags (personnalisés ou 'but')"""
|
||||
for tag in tags_dict["personnalises"]:
|
||||
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
|
||||
info_tag = tags_dict["personnalises"][tag]
|
||||
# Les moyennes générales par UEs
|
||||
moy_ues_tag = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=info_tag, pole=None
|
||||
)
|
||||
# Mémorise les moyennes
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_ues_tag,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute les moyennes par UEs + la moyenne générale (but)
|
||||
moy_gen = self.compute_moy_gen()
|
||||
self.moyennes_tags["but"] = pe_moytag.MoyennesTag(
|
||||
"but",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute la moyenne générale par ressources
|
||||
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
|
||||
moy_res_gen = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.RESSOURCE
|
||||
)
|
||||
self.moyennes_tags["ressources"] = pe_moytag.MoyennesTag(
|
||||
"ressources",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_res_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute la moyenne générale par saes
|
||||
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
|
||||
moy_saes_gen = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.SAE
|
||||
)
|
||||
self.moyennes_tags["saes"] = pe_moytag.MoyennesTag(
|
||||
"saes",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_saes_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Tous les tags
|
||||
self.tags_sorted = self.get_all_significant_tags()
|
||||
"""Tags (personnalisés+compétences) par ordre alphabétique"""
|
||||
|
||||
def get_repr(self, verbose=False) -> str:
|
||||
"""Nom affiché pour le semestre taggué, de la forme (par ex.):
|
||||
|
||||
* S1#69 si verbose est False
|
||||
* S1 FI 2023 si verbose est True
|
||||
"""
|
||||
if not verbose:
|
||||
return f"{self.formsemestre}#{self.formsemestre.formsemestre_id}"
|
||||
else:
|
||||
return pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
|
||||
|
||||
def _get_matrice_coeffs(
|
||||
self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns]
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataFrame donnant les coefficients à appliquer aux UEs
|
||||
dans le calcul de la moyenne générale (toutes UEs confondues).
|
||||
Prend en compte l'inscription des étudiants aux UEs en fonction de leur parcours
|
||||
(cf. ues_inscr_parcours_df).
|
||||
|
||||
Args:
|
||||
ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs
|
||||
ues_standards: Les UEs standards à prendre en compte
|
||||
|
||||
Returns:
|
||||
Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs
|
||||
"""
|
||||
matrice_coeffs_moy_gen = ues_inscr_parcours_df * [
|
||||
ue.ects for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé
|
||||
]
|
||||
matrice_coeffs_moy_gen.columns = [
|
||||
self.ues_to_acronymes[ue.id] for ue in ues_standards
|
||||
]
|
||||
# Tri par etudids (dim 0) et par acronymes (dim 1)
|
||||
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index()
|
||||
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index(axis=1)
|
||||
return matrice_coeffs_moy_gen
|
||||
|
||||
def _get_capitalisations(self, ues_standards) -> pd.DataFrame:
|
||||
"""Renvoie un dataFrame résumant les UEs capitalisables par les
|
||||
étudiants, d'après les décisions de jury (sous réserve qu'elles existent).
|
||||
|
||||
Args:
|
||||
ues_standards: Liste des UEs standards (notamment autres que le sport)
|
||||
Returns:
|
||||
Un dataFrame etudids x acronymes_UEs dont les valeurs sont ``True`` si l'UE
|
||||
est capitalisable, ``False`` sinon
|
||||
"""
|
||||
capitalisations = pd.DataFrame(
|
||||
False, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
self.get_formsemestre_validations() # charge les validations
|
||||
res_jury = self.validations
|
||||
if res_jury:
|
||||
for etud in self.etuds:
|
||||
etudid = etud.etudid
|
||||
decisions = res_jury.decisions_jury_ues.get(etudid, {})
|
||||
for ue in ues_standards:
|
||||
if ue.id in decisions and decisions[ue.id]["code"] == sco_codes.ADM:
|
||||
capitalisations.loc[etudid, ue.acronyme] = True
|
||||
# Tri par etudis et par accronyme d'UE
|
||||
capitalisations = capitalisations.sort_index()
|
||||
capitalisations = capitalisations.sort_index(axis=1)
|
||||
return capitalisations
|
||||
|
||||
def compute_moy_ues_tag(
|
||||
self,
|
||||
ues_inscr_parcours_df: pd.DataFrame,
|
||||
info_tag: dict[int, dict] = None,
|
||||
pole=None,
|
||||
) -> pd.DataFrame:
|
||||
"""Calcule la moyenne par UE des étudiants pour un tag donné,
|
||||
en ayant connaissance des informations sur le tag et des inscriptions des étudiants aux différentes UEs.
|
||||
|
||||
info_tag détermine les modules pris en compte :
|
||||
* si non `None`, seuls les modules rattachés au tag sont pris en compte
|
||||
* si `None`, tous les modules (quelque soit leur rattachement au tag) sont pris
|
||||
en compte (sert au calcul de la moyenne générale par ressource ou SAE)
|
||||
|
||||
ues_inscr_parcours_df détermine les UEs pour lesquels le calcul d'une moyenne à un sens.
|
||||
|
||||
`pole` détermine les modules pris en compte :
|
||||
|
||||
* si `pole` vaut `ModuleType.RESSOURCE`, seules les ressources sont prises
|
||||
en compte (moyenne de ressources par UEs)
|
||||
* si `pole` vaut `ModuleType.SAE`, seules les SAEs sont prises en compte
|
||||
* si `pole` vaut `None` (ou toute autre valeur),
|
||||
tous les modules sont pris en compte (moyenne d'UEs)
|
||||
|
||||
Les informations sur le tag sont un dictionnaire listant les modimpl_id rattachés au tag,
|
||||
et pour chacun leur éventuel coefficient de **repondération**.
|
||||
|
||||
Args:
|
||||
ues_inscr_parcours_df: L'inscription aux UEs
|
||||
Returns:
|
||||
Le dataframe des moyennes du tag par UE
|
||||
"""
|
||||
modimpls_sorted = self.formsemestre.modimpls_sorted
|
||||
|
||||
# Adaptation du mask de calcul des moyennes au tag visé
|
||||
modimpls_mask = []
|
||||
for modimpl in modimpls_sorted:
|
||||
module = modimpl.module # Le module
|
||||
mask = module.ue.type == sco_codes.UE_STANDARD # Est-ce une UE stantard ?
|
||||
if pole == ModuleType.RESSOURCE:
|
||||
mask &= module.module_type == ModuleType.RESSOURCE
|
||||
elif pole == ModuleType.SAE:
|
||||
mask &= module.module_type == ModuleType.SAE
|
||||
modimpls_mask += [mask]
|
||||
|
||||
# Prise en compte du tag
|
||||
if info_tag:
|
||||
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
|
||||
for i, modimpl in enumerate(modimpls_sorted):
|
||||
if modimpl.moduleimpl_id not in info_tag:
|
||||
modimpls_mask[i] = False
|
||||
|
||||
# Applique la pondération des coefficients
|
||||
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
|
||||
if info_tag:
|
||||
for modimpl_id in info_tag:
|
||||
ponderation = info_tag[modimpl_id]["ponderation"]
|
||||
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
|
||||
|
||||
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)
|
||||
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
|
||||
self.sem_cube,
|
||||
self.etuds,
|
||||
self.formsemestre.modimpls_sorted,
|
||||
self.modimpl_inscr_df,
|
||||
modimpl_coefs_ponderes_df,
|
||||
modimpls_mask,
|
||||
self.dispense_ues,
|
||||
block=self.formsemestre.block_moyennes,
|
||||
)
|
||||
|
||||
# Ne conserve que les UEs standards
|
||||
colonnes = [ue.id for ue in self.ues_standards]
|
||||
moyennes_ues_tag = moyennes_ues_tag[colonnes]
|
||||
|
||||
# Applique le masque d'inscription aux UE pour ne conserver que les UE dans lequel l'étudiant est inscrit
|
||||
moyennes_ues_tag = moyennes_ues_tag[colonnes] * ues_inscr_parcours_df[colonnes]
|
||||
|
||||
# Transforme les UEs en acronyme
|
||||
acronymes = [self.ues_to_acronymes[ue.id] for ue in self.ues_standards]
|
||||
moyennes_ues_tag.columns = acronymes
|
||||
|
||||
# Tri par etudids et par ordre alphabétique d'acronyme
|
||||
moyennes_ues_tag = moyennes_ues_tag.sort_index()
|
||||
moyennes_ues_tag = moyennes_ues_tag.sort_index(axis=1)
|
||||
|
||||
return moyennes_ues_tag
|
||||
|
||||
def compute_moy_gen(self):
|
||||
"""Récupère les moyennes des UEs pour le calcul de la moyenne générale,
|
||||
en associant à chaque UE.id son acronyme (toutes UEs confondues)
|
||||
"""
|
||||
df_ues = pd.DataFrame(
|
||||
{ue.id: self.etud_moy_ue[ue.id] for ue in self.ues_standards},
|
||||
index=self.etudids,
|
||||
)
|
||||
# Transforme les UEs en acronyme
|
||||
colonnes = df_ues.columns
|
||||
acronymes = [self.ues_to_acronymes[col] for col in colonnes]
|
||||
df_ues.columns = acronymes
|
||||
|
||||
# Tri par ordre aphabétique de colonnes
|
||||
df_ues.sort_index(axis=1)
|
||||
|
||||
return df_ues
|
||||
|
||||
def _get_tags_dict(self, avec_moyennes_tags=True):
|
||||
"""Renvoie les tags personnalisés (déduits des modules du semestre)
|
||||
et les tags automatiques ('but'), et toutes leurs informations,
|
||||
dans un dictionnaire de la forme :
|
||||
|
||||
``{"personnalises": {tag: info_sur_le_tag},
|
||||
"auto": {tag: {}}``
|
||||
|
||||
Returns:
|
||||
Le dictionnaire structuré des tags ("personnalises" vs. "auto")
|
||||
"""
|
||||
dict_tags = {"personnalises": dict(), "auto": dict()}
|
||||
|
||||
if avec_moyennes_tags:
|
||||
# Les tags perso (seulement si l'option d'utiliser les tags perso est choisie)
|
||||
dict_tags["personnalises"] = get_synthese_tags_personnalises_semestre(
|
||||
self.formsemestre
|
||||
)
|
||||
|
||||
# Les tags automatiques
|
||||
# Déduit des compétences
|
||||
# dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
|
||||
# noms_tags_comp = list(set(dict_ues_competences.values()))
|
||||
|
||||
# BUT
|
||||
dict_tags["auto"] = {"but": {}, "ressources": {}, "saes": {}}
|
||||
return dict_tags
|
||||
|
||||
def _check_tags(self, dict_tags):
|
||||
"""Vérifie l'unicité des tags"""
|
||||
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
|
||||
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
|
||||
noms_tags = noms_tags_perso + noms_tags_auto
|
||||
|
||||
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
|
||||
|
||||
if intersection:
|
||||
liste_intersection = "\n".join(
|
||||
[f"<li><code>{tag}</code></li>" for tag in intersection]
|
||||
)
|
||||
s = "s" if len(intersection) > 1 else ""
|
||||
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
|
||||
programme de formation fait parti des tags réservés. En particulier,
|
||||
votre semestre <em>{self.formsemestre.titre_annee()}</em>
|
||||
contient le{s} tag{s} réservé{s} suivant :
|
||||
<ul>
|
||||
{liste_intersection}
|
||||
</ul>
|
||||
Modifiez votre programme de formation pour le{s} supprimer.
|
||||
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
|
||||
"""
|
||||
raise ScoValueError(message)
|
||||
|
||||
|
||||
def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
|
||||
"""Etant données les implémentations des modules du semestre (modimpls),
|
||||
synthétise les tags renseignés dans le programme pédagogique &
|
||||
associés aux modules du semestre,
|
||||
en les associant aux modimpls qui les concernent (modimpl_id) et
|
||||
au coeff de repondération fournie avec le tag (par défaut 1 si non indiquée)).
|
||||
|
||||
Le dictionnaire fournit est de la forme :
|
||||
|
||||
``{ tag : { modimplid: {"modimpl": ModImpl,
|
||||
"ponderation": coeff_de_reponderation}
|
||||
} }``
|
||||
|
||||
Args:
|
||||
formsemestre: Le formsemestre à la base de la recherche des tags
|
||||
|
||||
Return:
|
||||
Un dictionnaire décrivant les tags
|
||||
"""
|
||||
synthese_tags = {}
|
||||
|
||||
# Instance des modules du semestre
|
||||
modimpls = formsemestre.modimpls_sorted
|
||||
|
||||
for modimpl in modimpls:
|
||||
modimpl_id = modimpl.id
|
||||
|
||||
# Liste des tags pour le module concerné
|
||||
tags = sco_tag_module.module_tag_list(modimpl.module.id)
|
||||
|
||||
# Traitement des tags recensés, chacun pouvant étant de la forme
|
||||
# "mathématiques", "théorie", "pe:0", "maths:2"
|
||||
for tag in tags:
|
||||
# Extraction du nom du tag et du coeff de pondération
|
||||
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
|
||||
|
||||
# Ajout d'une clé pour le tag
|
||||
if tagname not in synthese_tags:
|
||||
synthese_tags[tagname] = {}
|
||||
|
||||
# Ajout du module (modimpl) au tagname considéré
|
||||
synthese_tags[tagname][modimpl_id] = {
|
||||
"modimpl": modimpl, # les données sur le module
|
||||
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
|
||||
}
|
||||
|
||||
return synthese_tags
|
|
@ -0,0 +1,406 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.pe import pe_affichage, pe_comp
|
||||
import app.pe.moys.pe_ressemtag as pe_ressemtag
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.pe.moys import pe_moytag, pe_tabletags
|
||||
import app.pe.rcss.pe_trajectoires as pe_trajectoires
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class SxTag(pe_tabletags.TableTag):
|
||||
def __init__(
|
||||
self,
|
||||
sxtag_id: (str, int),
|
||||
semx: pe_trajectoires.SemX,
|
||||
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
|
||||
):
|
||||
"""Calcule les moyennes/classements par tag d'un semestre de type 'Sx'
|
||||
(par ex. 'S1', 'S2', ...) représentés par acronyme d'UE.
|
||||
|
||||
Il représente :
|
||||
|
||||
* pour les étudiants *non redoublants* : moyennes/classements
|
||||
du semestre suivi
|
||||
* pour les étudiants *redoublants* : une fusion des moyennes/classements
|
||||
dans les (2) 'Sx' qu'il a suivi, en exploitant les informations de capitalisation :
|
||||
meilleure moyenne entre l'UE capitalisée et l'UE refaite (la notion de meilleure
|
||||
s'appliquant à la moyenne d'UE)
|
||||
|
||||
Un SxTag (regroupant potentiellement plusieurs semestres) est identifié
|
||||
par un tuple ``(Sx, fid)`` où :
|
||||
|
||||
* ``x`` est le rang (semestre_id) du semestre
|
||||
* ``fid`` le formsemestre_id du semestre final (le plus récent) du regroupement.
|
||||
|
||||
Les **tags**, les **UE** et les inscriptions aux UEs (pour les étudiants)
|
||||
considérés sont uniquement ceux du semestre final.
|
||||
|
||||
Args:
|
||||
sxtag_id: L'identifiant de SxTag
|
||||
ressembuttags: Un dictionnaire de la forme `{fid: ResSemBUTTag(fid)}` donnant
|
||||
les semestres à regrouper et les résultats/moyennes par tag des
|
||||
semestres
|
||||
"""
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
assert sxtag_id and len(sxtag_id) == 2 and sxtag_id[1] in ressembuttags
|
||||
|
||||
self.sxtag_id: (str, int) = sxtag_id
|
||||
"""Identifiant du SxTag de la forme (nom_Sx, fid_semestre_final)"""
|
||||
assert (
|
||||
len(self.sxtag_id) == 2
|
||||
and isinstance(self.sxtag_id[0], str)
|
||||
and isinstance(self.sxtag_id[1], int)
|
||||
), "Format de l'identifiant du SxTag non respecté"
|
||||
|
||||
self.agregat = sxtag_id[0]
|
||||
"""Nom de l'aggrégat du RCS"""
|
||||
|
||||
self.semx = semx
|
||||
"""Le SemX sur lequel il s'appuie"""
|
||||
assert semx.rcs_id == sxtag_id, "Problème de correspondance SxTag/SemX"
|
||||
|
||||
# Les resultats des semestres taggués à prendre en compte dans le SemX
|
||||
self.ressembuttags = {
|
||||
fid: ressembuttags[fid] for fid in semx.semestres_aggreges
|
||||
}
|
||||
"""Les ResSemBUTTags à regrouper dans le SxTag"""
|
||||
|
||||
# Les données du semestre final
|
||||
self.fid_final = sxtag_id[1]
|
||||
self.ressembuttag_final = ressembuttags[self.fid_final]
|
||||
"""Le ResSemBUTTag final"""
|
||||
|
||||
# Ajoute les etudids et les états civils
|
||||
self.etuds = self.ressembuttag_final.etuds
|
||||
"""Les étudiants (extraits du ReSemBUTTag final)"""
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les etudids triés"""
|
||||
|
||||
# Affichage
|
||||
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
|
||||
|
||||
# Les tags
|
||||
self.tags_sorted = self.ressembuttag_final.tags_sorted
|
||||
"""Tags (extraits du ReSemBUTTag final)"""
|
||||
aff_tag = pe_affichage.repr_tags(self.tags_sorted)
|
||||
pe_affichage.pe_print(f"--> Tags : {aff_tag}")
|
||||
|
||||
# Les UE données par leur acronyme
|
||||
self.acronymes_sorted = self.ressembuttag_final.acronymes_sorted
|
||||
"""Les acronymes des UEs (extraits du ResSemBUTTag final)"""
|
||||
|
||||
# L'association UE-compétences extraites du dernier semestre
|
||||
self.acronymes_ues_to_competences = (
|
||||
self.ressembuttag_final.acronymes_ues_to_competences
|
||||
)
|
||||
"""L'association acronyme d'UEs -> compétence"""
|
||||
self.competences_sorted = sorted(self.acronymes_ues_to_competences.values())
|
||||
"""Les compétences triées par nom"""
|
||||
|
||||
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
|
||||
|
||||
# Les coeffs pour la moyenne générale (traduisant également l'inscription
|
||||
# des étudiants aux UEs) (etudids_sorted x acronymes_ues_sorted)
|
||||
self.matrice_coeffs_moy_gen = self.ressembuttag_final.matrice_coeffs_moy_gen
|
||||
"""La matrice des coeffs pour la moyenne générale"""
|
||||
aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {aff}"
|
||||
)
|
||||
|
||||
# Masque des inscriptions et des capitalisations
|
||||
self.masque_df = None
|
||||
"""Le DataFrame traduisant les capitalisations des différents semestres"""
|
||||
self.masque_df, masque_cube = compute_masques_capitalisation_cube(
|
||||
self.etudids_sorted,
|
||||
self.acronymes_sorted,
|
||||
self.ressembuttags,
|
||||
self.fid_final,
|
||||
)
|
||||
pe_affichage.aff_capitalisations(
|
||||
self.etuds,
|
||||
self.ressembuttags,
|
||||
self.fid_final,
|
||||
self.acronymes_sorted,
|
||||
self.masque_df,
|
||||
)
|
||||
|
||||
# Les moyennes par tag
|
||||
self.moyennes_tags: dict[str, pd.DataFrame] = {}
|
||||
"""Moyennes aux UEs (identifiées par leur acronyme) des différents tags"""
|
||||
|
||||
if self.tags_sorted:
|
||||
pe_affichage.pe_print("--> Calcul des moyennes par tags :")
|
||||
|
||||
for tag in self.tags_sorted:
|
||||
pe_affichage.pe_print(f" > MoyTag 👜{tag}")
|
||||
|
||||
# Masque des inscriptions aux UEs (extraits de la matrice de coefficients)
|
||||
inscr_mask: np.array = ~np.isnan(self.matrice_coeffs_moy_gen.to_numpy())
|
||||
|
||||
# Moyennes (tous modules confondus)
|
||||
if not self.has_notes_tag(tag):
|
||||
pe_affichage.pe_print(
|
||||
f" --> Semestre (final) actuellement sans notes"
|
||||
)
|
||||
matrice_moys_ues = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
else:
|
||||
# Moyennes tous modules confondus
|
||||
### Cube de note etudids x UEs tous modules confondus
|
||||
notes_df_gen, notes_cube_gen = self.compute_notes_ues_cube(tag)
|
||||
|
||||
# DataFrame des moyennes (tous modules confondus)
|
||||
matrice_moys_ues = self.compute_notes_ues(
|
||||
notes_cube_gen, masque_cube, inscr_mask
|
||||
)
|
||||
|
||||
# Mémorise les infos pour la moyenne au tag
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
matrice_moys_ues,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Affichage de debug
|
||||
aff = pe_affichage.repr_profil_coeffs(
|
||||
self.matrice_coeffs_moy_gen, with_index=True
|
||||
)
|
||||
pe_affichage.pe_print(f" > Moyenne générale calculée avec : {aff}")
|
||||
|
||||
def has_notes_tag(self, tag):
|
||||
"""Détermine si le SxTag, pour un tag donné, est en cours d'évaluation.
|
||||
Si oui, n'a pas (encore) de notes dans le resformsemestre final.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
|
||||
Returns:
|
||||
True si a des notes, False sinon
|
||||
"""
|
||||
moy_tag_dernier_sem = self.ressembuttag_final.moyennes_tags[tag]
|
||||
return moy_tag_dernier_sem.has_notes()
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Egalité de 2 SxTag sur la base de leur identifiant"""
|
||||
return self.sxtag_id == other.sxtag_id
|
||||
|
||||
def get_repr(self, verbose=False) -> str:
|
||||
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
|
||||
est basée)"""
|
||||
if verbose:
|
||||
return f"SXTag basé sur {self.semx.get_repr()}"
|
||||
else:
|
||||
# affichage = [str(fid) for fid in self.ressembuttags]
|
||||
return f"SXTag {self.agregat}#{self.fid_final}"
|
||||
|
||||
def compute_notes_ues_cube(self, tag) -> (pd.DataFrame, np.array):
|
||||
"""Construit le cube de notes des UEs (etudid x accronyme_ue x semestre_aggregé)
|
||||
nécessaire au calcul des moyennes du tag pour le RCS Sx.
|
||||
(Renvoie également le dataframe associé pour debug).
|
||||
|
||||
Args:
|
||||
tag: Le tag considéré (personalisé ou "but")
|
||||
"""
|
||||
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
|
||||
# etudids_sorted = etudids_sorted
|
||||
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
|
||||
semestres_id = list(self.ressembuttags.keys())
|
||||
|
||||
dfs = {}
|
||||
|
||||
for frmsem_id in semestres_id:
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
|
||||
# Charge les notes du semestre tag
|
||||
sem_tag = self.ressembuttags[frmsem_id]
|
||||
moys_tag = sem_tag.moyennes_tags[tag]
|
||||
notes = moys_tag.matrice_notes_gen # dataframe etudids x ues
|
||||
|
||||
# les étudiants et les acronymes communs
|
||||
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
|
||||
df, notes
|
||||
)
|
||||
|
||||
# Recopie
|
||||
df.loc[etudids_communs, acronymes_communs] = notes.loc[
|
||||
etudids_communs, acronymes_communs
|
||||
]
|
||||
|
||||
# Supprime tout ce qui n'est pas numérique
|
||||
for col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# Stocke le df
|
||||
dfs[frmsem_id] = df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
|
||||
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
|
||||
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
|
||||
return dfs, etudids_x_ues_x_semestres
|
||||
|
||||
def compute_notes_ues(
|
||||
self,
|
||||
set_cube: np.array,
|
||||
masque_cube: np.array,
|
||||
inscr_mask: np.array,
|
||||
) -> pd.DataFrame:
|
||||
"""Calcule la moyenne par UEs à un tag donné en prenant la note maximum (UE
|
||||
par UE) obtenue par un étudiant à un semestre.
|
||||
|
||||
Args:
|
||||
set_cube: notes moyennes aux modules ndarray
|
||||
(semestre_ids x etudids x UEs), des floats avec des NaN
|
||||
masque_cube: masque indiquant si la note doit être prise en compte ndarray
|
||||
(semestre_ids x etudids x UEs), des 1.0 ou des 0.0
|
||||
inscr_mask: masque etudids x UE traduisant les inscriptions des
|
||||
étudiants aux UE (du semestre terminal)
|
||||
Returns:
|
||||
Un DataFrame avec pour columns les moyennes par ues,
|
||||
et pour rows les etudid
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube) trié par etudid
|
||||
# acronymes_sorted: liste des acronymes des ues (dim. 1 du cube) trié par acronyme
|
||||
nb_etuds, nb_ues, nb_semestres = set_cube.shape
|
||||
nb_etuds_mask, nb_ues_mask = inscr_mask.shape
|
||||
# assert nb_etuds == len(self.etudids_sorted)
|
||||
# assert nb_ues == len(self.acronymes_sorted)
|
||||
# assert nb_etuds == nb_etuds_mask
|
||||
# assert nb_ues == nb_ues_mask
|
||||
|
||||
# Entrées à garder dans le cube en fonction du masque d'inscription aux UEs du parcours
|
||||
inscr_mask_3D = np.stack([inscr_mask] * nb_semestres, axis=-1)
|
||||
set_cube = set_cube * inscr_mask_3D
|
||||
|
||||
# Entrées à garder en fonction des UEs capitalisées ou non
|
||||
set_cube = set_cube * masque_cube
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube)
|
||||
|
||||
# Enlève les NaN du cube pour les entrées manquantes : NaN -> -1.0
|
||||
set_cube_no_nan = np.nan_to_num(set_cube, nan=-1.0)
|
||||
|
||||
# Les moyennes par ues
|
||||
# TODO: Pour l'instant un max sans prise en compte des UE capitalisées
|
||||
etud_moy = np.max(set_cube_no_nan, axis=2)
|
||||
|
||||
# Fix les max non calculé -1 -> NaN
|
||||
etud_moy[etud_moy < 0] = np.NaN
|
||||
|
||||
# Le dataFrame
|
||||
etud_moy_tag_df = pd.DataFrame(
|
||||
etud_moy,
|
||||
index=self.etudids_sorted, # les etudids
|
||||
columns=self.acronymes_sorted, # les acronymes d'UEs
|
||||
)
|
||||
|
||||
etud_moy_tag_df = etud_moy_tag_df.fillna(np.nan)
|
||||
|
||||
return etud_moy_tag_df
|
||||
|
||||
|
||||
def compute_masques_capitalisation_cube(
|
||||
etudids_sorted: list[int],
|
||||
acronymes_sorted: list[str],
|
||||
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
|
||||
formsemestre_id_final: int,
|
||||
) -> (pd.DataFrame, np.array):
|
||||
"""Construit le cube traduisant les masques des UEs à prendre en compte dans le calcul
|
||||
des moyennes, en utilisant le dataFrame de capitalisations de chaque ResSemBUTTag
|
||||
|
||||
Ces masques contiennent : 1 si la note doit être prise en compte, 0 sinon
|
||||
|
||||
Le masque des UEs à prendre en compte correspondant au semestre final (identifié par
|
||||
son formsemestre_id_final) est systématiquement à 1 (puisque les résultats
|
||||
de ce semestre doivent systématiquement
|
||||
être pris en compte notamment pour les étudiants non redoublant).
|
||||
|
||||
Args:
|
||||
etudids_sorted: La liste des etudids triés par ordre croissant (dim 0)
|
||||
acronymes_sorted: La liste des acronymes de UEs triés par acronyme croissant (dim 1)
|
||||
ressembuttags: Le dictionnaire des résultats de semestres BUT (tous tags confondus)
|
||||
formsemestre_id_final: L'identifiant du formsemestre_id_final
|
||||
"""
|
||||
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
|
||||
# etudids_sorted = etudids_sorted
|
||||
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
|
||||
semestres_id = list(ressembuttags.keys())
|
||||
|
||||
dfs = {}
|
||||
|
||||
for frmsem_id in semestres_id:
|
||||
# Partant d'un dataframe contenant des 1.0
|
||||
if frmsem_id == formsemestre_id_final:
|
||||
df = pd.DataFrame(1.0, index=etudids_sorted, columns=acronymes_sorted)
|
||||
else: # semestres redoublés
|
||||
df = pd.DataFrame(0.0, index=etudids_sorted, columns=acronymes_sorted)
|
||||
|
||||
# Traitement des capitalisations : remplace les infos de capitalisations par les coeff 1 ou 0
|
||||
capitalisations = ressembuttags[frmsem_id].capitalisations
|
||||
capitalisations = capitalisations.replace(True, 1.0).replace(False, 0.0)
|
||||
|
||||
# Met à 0 les coeffs des UEs non capitalisées pour les étudiants
|
||||
# inscrits dans les 2 semestres: 1.0*False => 0.0
|
||||
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
|
||||
df, capitalisations
|
||||
)
|
||||
|
||||
df.loc[etudids_communs, acronymes_communs] = capitalisations.loc[
|
||||
etudids_communs, acronymes_communs
|
||||
]
|
||||
|
||||
# Stocke le df
|
||||
dfs[frmsem_id] = df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
|
||||
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
|
||||
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
|
||||
return dfs, etudids_x_ues_x_semestres
|
|
@ -0,0 +1,203 @@
|
|||
# -*- pole: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.models import Identite
|
||||
from app.pe.moys import pe_moytag
|
||||
|
||||
TAGS_RESERVES = ["but"]
|
||||
|
||||
CHAMPS_ADMINISTRATIFS = ["Civilité", "Nom", "Prénom"]
|
||||
|
||||
|
||||
class TableTag(object):
|
||||
def __init__(self):
|
||||
"""Classe centralisant différentes méthodes communes aux
|
||||
SemestreTag, TrajectoireTag, AggregatInterclassTag
|
||||
"""
|
||||
# Les étudiants
|
||||
# self.etuds: list[Identite] = None # A venir
|
||||
"""Les étudiants"""
|
||||
# self.etudids: list[int] = {}
|
||||
"""Les etudids"""
|
||||
|
||||
def add_etuds(self, etuds: list[Identite]):
|
||||
"""Mémorise les informations sur les étudiants
|
||||
|
||||
Args:
|
||||
etuds: la liste des identités de l'étudiant
|
||||
"""
|
||||
# self.etuds = etuds
|
||||
self.etudids = list({etud.etudid for etud in etuds})
|
||||
|
||||
def get_all_significant_tags(self):
|
||||
"""Liste des tags de la table, triée par ordre alphabétique,
|
||||
extraite des clés du dictionnaire ``moyennes_tags``, en ne
|
||||
considérant que les moyennes ayant des notes.
|
||||
|
||||
Returns:
|
||||
Liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
tag: str = ""
|
||||
moytag: pe_moytag.MoyennesTag = None
|
||||
for tag, moytag in self.moyennes_tags.items():
|
||||
if moytag.has_notes():
|
||||
tags.append(tag)
|
||||
return sorted(tags)
|
||||
|
||||
def to_df(
|
||||
self,
|
||||
administratif=True,
|
||||
aggregat=None,
|
||||
tags_cibles=None,
|
||||
cohorte=None,
|
||||
options={"min_max_moy": True},
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataframe listant toutes les données
|
||||
des moyennes/classements/nb_inscrits/min/max/moy
|
||||
des étudiants aux différents tags.
|
||||
|
||||
tags_cibles limitent le dataframe aux tags indiqués
|
||||
type_colonnes indiquent si les colonnes doivent être passées en multiindex
|
||||
|
||||
Args:
|
||||
administratif: Indique si les données administratives sont incluses
|
||||
aggregat: l'aggrégat représenté
|
||||
tags_cibles: la liste des tags ciblés
|
||||
cohorte: la cohorte représentée
|
||||
Returns:
|
||||
Le dataframe complet de synthèse
|
||||
"""
|
||||
# Les tags visés
|
||||
tags_tries = self.get_all_significant_tags()
|
||||
if not tags_cibles:
|
||||
tags_cibles = tags_tries
|
||||
tags_cibles = sorted(tags_cibles)
|
||||
|
||||
# Les tags visés avec des notes
|
||||
|
||||
# Les étudiants visés
|
||||
if administratif:
|
||||
df = df_administratif(self.etuds, aggregat=aggregat, cohorte=cohorte)
|
||||
else:
|
||||
df = pd.DataFrame(index=self.etudids)
|
||||
|
||||
if not self.is_significatif():
|
||||
return df
|
||||
|
||||
# Ajout des données par tags
|
||||
for tag in tags_cibles:
|
||||
if tag in self.moyennes_tags:
|
||||
moy_tag_df = self.moyennes_tags[tag].to_df(
|
||||
aggregat=aggregat, cohorte=cohorte, options=options
|
||||
)
|
||||
df = df.join(moy_tag_df)
|
||||
|
||||
# Tri par nom, prénom
|
||||
if administratif:
|
||||
colonnes_tries = [
|
||||
_get_champ_administratif(champ, aggregat=aggregat, cohorte=cohorte)
|
||||
for champ in CHAMPS_ADMINISTRATIFS[1:]
|
||||
] # Nom + Prénom
|
||||
df = df.sort_values(by=colonnes_tries)
|
||||
return df
|
||||
|
||||
def has_etuds(self):
|
||||
"""Indique si un tabletag contient des étudiants"""
|
||||
return len(self.etuds) > 0
|
||||
|
||||
def is_significatif(self):
|
||||
"""Indique si une tabletag a des données"""
|
||||
# A des étudiants
|
||||
if not self.etuds:
|
||||
return False
|
||||
# A des tags avec des notes
|
||||
tags_tries = self.get_all_significant_tags()
|
||||
if not tags_tries:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_champ_administratif(champ, aggregat=None, cohorte=None):
|
||||
"""Pour un champ donné, renvoie l'index (ou le multindex)
|
||||
à intégrer au dataframe"""
|
||||
liste = []
|
||||
if aggregat != None:
|
||||
liste += [aggregat]
|
||||
liste += ["Administratif", "Identité"]
|
||||
if cohorte != None:
|
||||
liste += [champ]
|
||||
liste += [champ]
|
||||
return "|".join(liste)
|
||||
|
||||
|
||||
def df_administratif(
|
||||
etuds: list[Identite], aggregat=None, cohorte=None
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataframe donnant les données administratives
|
||||
des étudiants du TableTag
|
||||
|
||||
Args:
|
||||
etuds: Identité des étudiants générant les données administratives
|
||||
"""
|
||||
identites = {etud.etudid: etud for etud in etuds}
|
||||
|
||||
donnees = {}
|
||||
etud: Identite = None
|
||||
for etudid, etud in identites.items():
|
||||
data = {
|
||||
CHAMPS_ADMINISTRATIFS[0]: etud.civilite_str,
|
||||
CHAMPS_ADMINISTRATIFS[1]: etud.nom,
|
||||
CHAMPS_ADMINISTRATIFS[2]: etud.prenom_str,
|
||||
}
|
||||
donnees[etudid] = {
|
||||
_get_champ_administratif(champ, aggregat, cohorte): data[champ]
|
||||
for champ in CHAMPS_ADMINISTRATIFS
|
||||
}
|
||||
|
||||
colonnes = [
|
||||
_get_champ_administratif(champ, aggregat, cohorte)
|
||||
for champ in CHAMPS_ADMINISTRATIFS
|
||||
]
|
||||
|
||||
df = pd.DataFrame.from_dict(donnees, orient="index", columns=colonnes)
|
||||
df = df.sort_values(by=colonnes[1:])
|
||||
return df
|
|
@ -0,0 +1,234 @@
|
|||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""Affichages, debug
|
||||
"""
|
||||
|
||||
from flask import g
|
||||
from app import log
|
||||
from app.pe.rcss import pe_rcs
|
||||
|
||||
PE_DEBUG = False
|
||||
|
||||
|
||||
# On stocke les logs PE dans g.scodoc_pe_log
|
||||
# pour ne pas modifier les nombreux appels à pe_print.
|
||||
def pe_start_log() -> list[str]:
|
||||
"Initialize log"
|
||||
g.scodoc_pe_log = []
|
||||
return g.scodoc_pe_log
|
||||
|
||||
|
||||
def pe_print(*a, **cles):
|
||||
"Log (or print in PE_DEBUG mode) and store in g"
|
||||
if PE_DEBUG:
|
||||
msg = " ".join(a)
|
||||
print(msg)
|
||||
else:
|
||||
lines = getattr(g, "scodoc_pe_log")
|
||||
if lines is None:
|
||||
lines = pe_start_log()
|
||||
msg = " ".join(a)
|
||||
lines.append(msg)
|
||||
if "info" in cles:
|
||||
log(msg)
|
||||
|
||||
|
||||
def pe_get_log() -> str:
|
||||
"Renvoie une chaîne avec tous les messages loggués"
|
||||
return "\n".join(getattr(g, "scodoc_pe_log", []))
|
||||
|
||||
|
||||
# Affichage dans le tableur pe en cas d'absence de notes
|
||||
SANS_NOTE = "-"
|
||||
|
||||
|
||||
def repr_profil_coeffs(matrice_coeffs_moy_gen, with_index=False):
|
||||
"""Affiche les différents types de coefficients (appelés profil)
|
||||
d'une matrice_coeffs_moy_gen (pour debug)
|
||||
"""
|
||||
|
||||
# Les profils des coeffs d'UE (pour debug)
|
||||
profils = []
|
||||
index_a_profils = {}
|
||||
for i in matrice_coeffs_moy_gen.index:
|
||||
val = matrice_coeffs_moy_gen.loc[i].fillna("-")
|
||||
val = " | ".join([str(v) for v in val])
|
||||
if val not in profils:
|
||||
profils += [val]
|
||||
index_a_profils[val] = [str(i)]
|
||||
else:
|
||||
index_a_profils[val] += [str(i)]
|
||||
|
||||
# L'affichage
|
||||
if len(profils) > 1:
|
||||
if with_index:
|
||||
elmts = [
|
||||
" " * 10
|
||||
+ prof
|
||||
+ " (par ex. "
|
||||
+ ", ".join(index_a_profils[prof][:10])
|
||||
+ ")"
|
||||
for prof in profils
|
||||
]
|
||||
else:
|
||||
elmts = [" " * 10 + prof for prof in profils]
|
||||
profils_aff = "\n" + "\n".join(elmts)
|
||||
else:
|
||||
profils_aff = "\n".join(profils)
|
||||
return profils_aff
|
||||
|
||||
|
||||
def repr_asso_ue_comp(acronymes_ues_to_competences):
|
||||
"""Représentation textuelle de l'association UE -> Compétences
|
||||
fournies dans acronymes_ues_to_competences
|
||||
"""
|
||||
champs = acronymes_ues_to_competences.keys()
|
||||
champs = sorted(champs)
|
||||
aff_comp = []
|
||||
for acro in champs:
|
||||
aff_comp += [f"📍{acro} (∈ 💡{acronymes_ues_to_competences[acro]})"]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
|
||||
def aff_UEs(champs):
|
||||
"""Représentation textuelle des UEs fournies dans `champs`"""
|
||||
champs_tries = sorted(champs)
|
||||
aff_comp = []
|
||||
|
||||
for comp in champs_tries:
|
||||
aff_comp += ["📍" + comp]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
|
||||
def aff_competences(champs):
|
||||
"""Affiche les compétences"""
|
||||
champs_tries = sorted(champs)
|
||||
aff_comp = []
|
||||
|
||||
for comp in champs_tries:
|
||||
aff_comp += ["💡" + comp]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
|
||||
def repr_tags(tags):
|
||||
"""Affiche les tags"""
|
||||
tags_tries = sorted(tags)
|
||||
aff_tag = ["👜" + tag for tag in tags_tries]
|
||||
return ", ".join(aff_tag)
|
||||
|
||||
|
||||
def aff_tags_par_categories(dict_tags):
|
||||
"""Etant donné un dictionnaire de tags, triés
|
||||
par catégorie (ici "personnalisés" ou "auto")
|
||||
représentation textuelle des tags
|
||||
"""
|
||||
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
|
||||
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
|
||||
if noms_tags_perso:
|
||||
aff_tags_perso = ", ".join([f"👜{nom}" for nom in noms_tags_perso])
|
||||
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
|
||||
return f"Tags du programme de formation : {aff_tags_perso} + Automatiques : {aff_tags_auto}"
|
||||
else:
|
||||
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
|
||||
return f"Tags automatiques {aff_tags_auto} (aucun tag personnalisé)"
|
||||
|
||||
# Affichage
|
||||
|
||||
|
||||
def aff_trajectoires_suivies_par_etudiants(etudiants):
|
||||
"""Affiche les trajectoires (regroupement de (form)semestres)
|
||||
amenant un étudiant du S1 à un semestre final"""
|
||||
# Affichage pour debug
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
|
||||
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} (#{etudid}) :")
|
||||
trajectoires = etudiants.trajectoires[etudid]
|
||||
for nom_rcs, rcs in trajectoires.items():
|
||||
if rcs:
|
||||
pe_print(f" > RCS ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
|
||||
def aff_semXs_suivis_par_etudiants(etudiants):
|
||||
"""Affiche les SemX (regroupement de semestres de type Sx)
|
||||
amenant un étudiant à valider un Sx"""
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} :")
|
||||
for nom_rcs, rcs in etudiants.semXs[etudid].items():
|
||||
if rcs:
|
||||
pe_print(f" > SemX ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
vides = []
|
||||
for nom_rcs in pe_rcs.TOUS_LES_SEMESTRES:
|
||||
les_semX_suivis = []
|
||||
for no_etud, etudid in jeunes:
|
||||
if etudiants.semXs[etudid][nom_rcs]:
|
||||
les_semX_suivis.append(etudiants.semXs[etudid][nom_rcs])
|
||||
if not les_semX_suivis:
|
||||
vides += [nom_rcs]
|
||||
vides = sorted(list(set(vides)))
|
||||
pe_print(f"⚠️ SemX sans données : {', '.join(vides)}")
|
||||
|
||||
|
||||
def aff_capitalisations(etuds, ressembuttags, fid_final, acronymes_sorted, masque_df):
|
||||
"""Affichage des capitalisations du sxtag pour debug"""
|
||||
aff_cap = []
|
||||
for etud in etuds:
|
||||
cap = []
|
||||
for frmsem_id in ressembuttags:
|
||||
if frmsem_id != fid_final:
|
||||
for accr in acronymes_sorted:
|
||||
if masque_df[frmsem_id].loc[etud.etudid, accr] > 0.0:
|
||||
cap += [accr]
|
||||
if cap:
|
||||
aff_cap += [f" > {etud.nomprenom} : {', '.join(cap)}"]
|
||||
if aff_cap:
|
||||
pe_print(f"--> ⚠️ Capitalisations :")
|
||||
pe_print("\n".join(aff_cap))
|
||||
|
||||
|
||||
def repr_comp_et_ues(acronymes_ues_to_competences):
|
||||
"""Affichage pour debug"""
|
||||
aff_comp = []
|
||||
competences_sorted = sorted(acronymes_ues_to_competences.keys())
|
||||
for comp in competences_sorted:
|
||||
liste = []
|
||||
for acro in acronymes_ues_to_competences:
|
||||
if acronymes_ues_to_competences[acro] == comp:
|
||||
liste += ["📍" + acro]
|
||||
aff_comp += [f" 💡{comp} (⇔ {', '.join(liste)})"]
|
||||
return "\n".join(aff_comp)
|
||||
|
||||
|
||||
def aff_rcsemxs_suivis_par_etudiants(etudiants):
|
||||
"""Affiche les RCSemX (regroupement de SemX)
|
||||
amenant un étudiant du S1 à un Sx"""
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
pe_print(f"-> {etat} {etudiants.identites[etudid].nomprenom} :")
|
||||
for nom_rcs, rcs in etudiants.rcsemXs[etudid].items():
|
||||
if rcs:
|
||||
pe_print(f" > RCSemX ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
vides = []
|
||||
for nom_rcs in pe_rcs.TOUS_LES_RCS:
|
||||
les_rcssemX_suivis = []
|
||||
for no_etud, etudid in jeunes:
|
||||
if etudiants.rcsemXs[etudid][nom_rcs]:
|
||||
les_rcssemX_suivis.append(etudiants.rcsemXs[etudid][nom_rcs])
|
||||
if not les_rcssemX_suivis:
|
||||
vides += [nom_rcs]
|
||||
vides = sorted(list(set(vides)))
|
||||
pe_print(f"⚠️ RCSemX vides : {', '.join(vides)}")
|
|
@ -1,517 +0,0 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
import os
|
||||
import codecs
|
||||
import re
|
||||
from app.pe import pe_tagtable
|
||||
from app.pe import pe_jurype
|
||||
from app.pe import pe_tools
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
import app.scodoc.notesdb as ndb
|
||||
from app import log
|
||||
from app.scodoc.gen_tables import GenTable, SeqGenTable
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_etud
|
||||
|
||||
|
||||
DEBUG = False # Pour debug et repérage des prints à changer en Log
|
||||
|
||||
DONNEE_MANQUANTE = (
|
||||
"" # Caractère de remplacement des données manquantes dans un avis PE
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_code_latex_from_modele(fichier):
|
||||
"""Lit le code latex à partir d'un modèle. Renvoie une chaine unicode.
|
||||
|
||||
Le fichier doit contenir le chemin relatif
|
||||
vers le modele : attention pas de vérification du format d'encodage
|
||||
Le fichier doit donc etre enregistré avec le même codage que ScoDoc (utf-8)
|
||||
"""
|
||||
fid_latex = codecs.open(fichier, "r", encoding=scu.SCO_ENCODING)
|
||||
un_avis_latex = fid_latex.read()
|
||||
fid_latex.close()
|
||||
return un_avis_latex
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_code_latex_from_scodoc_preference(formsemestre_id, champ="pe_avis_latex_tmpl"):
|
||||
"""
|
||||
Extrait le template (ou le tag d'annotation au regard du champ fourni) des préférences LaTeX
|
||||
et s'assure qu'il est renvoyé au format unicode
|
||||
"""
|
||||
template_latex = sco_preferences.get_preference(champ, formsemestre_id)
|
||||
|
||||
return template_latex or ""
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_tags_latex(code_latex):
|
||||
"""Recherche tous les tags présents dans un code latex (ce code étant obtenu
|
||||
à la lecture d'un modèle d'avis pe).
|
||||
Ces tags sont répérés par les balises **, débutant et finissant le tag
|
||||
et sont renvoyés sous la forme d'une liste.
|
||||
|
||||
result: liste de chaines unicode
|
||||
"""
|
||||
if code_latex:
|
||||
# changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})"
|
||||
res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex)
|
||||
return [tag[2:-2] for tag in res]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def comp_latex_parcourstimeline(etudiant, promo, taille=17):
|
||||
"""Interprète un tag dans un avis latex **parcourstimeline**
|
||||
et génère le code latex permettant de retracer le parcours d'un étudiant
|
||||
sous la forme d'une frise temporelle.
|
||||
Nota: modeles/parcourstimeline.tex doit avoir été inclu dans le préambule
|
||||
|
||||
result: chaine unicode (EV:)
|
||||
"""
|
||||
codelatexDebut = (
|
||||
""""
|
||||
\\begin{parcourstimeline}{**debut**}{**fin**}{**nbreSemestres**}{%d}
|
||||
"""
|
||||
% taille
|
||||
)
|
||||
|
||||
modeleEvent = """
|
||||
\\parcoursevent{**nosem**}{**nomsem**}{**descr**}
|
||||
"""
|
||||
|
||||
codelatexFin = """
|
||||
\\end{parcourstimeline}
|
||||
"""
|
||||
reslatex = codelatexDebut
|
||||
reslatex = reslatex.replace("**debut**", etudiant["entree"])
|
||||
reslatex = reslatex.replace("**fin**", str(etudiant["promo"]))
|
||||
reslatex = reslatex.replace("**nbreSemestres**", str(etudiant["nbSemestres"]))
|
||||
# Tri du parcours par ordre croissant : de la forme descr, nom sem date-date
|
||||
parcours = etudiant["parcours"][::-1] # EV: XXX je ne comprend pas ce commentaire ?
|
||||
|
||||
for no_sem in range(etudiant["nbSemestres"]):
|
||||
descr = modeleEvent
|
||||
nom_semestre_dans_parcours = parcours[no_sem]["nom_semestre_dans_parcours"]
|
||||
descr = descr.replace("**nosem**", str(no_sem + 1))
|
||||
if no_sem % 2 == 0:
|
||||
descr = descr.replace("**nomsem**", nom_semestre_dans_parcours)
|
||||
descr = descr.replace("**descr**", "")
|
||||
else:
|
||||
descr = descr.replace("**nomsem**", "")
|
||||
descr = descr.replace("**descr**", nom_semestre_dans_parcours)
|
||||
reslatex += descr
|
||||
reslatex += codelatexFin
|
||||
return reslatex
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def interprete_tag_latex(tag):
|
||||
"""Découpe les tags latex de la forme S1:groupe:dut:min et renvoie si possible
|
||||
le résultat sous la forme d'un quadruplet.
|
||||
"""
|
||||
infotag = tag.split(":")
|
||||
if len(infotag) == 4:
|
||||
return (
|
||||
infotag[0].upper(),
|
||||
infotag[1].lower(),
|
||||
infotag[2].lower(),
|
||||
infotag[3].lower(),
|
||||
)
|
||||
else:
|
||||
return (None, None, None, None)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_code_latex_avis_etudiant(
|
||||
donnees_etudiant, un_avis_latex, annotationPE, footer_latex, prefs
|
||||
):
|
||||
"""
|
||||
Renvoie le code latex permettant de générer l'avis d'un étudiant en utilisant ses
|
||||
donnees_etudiant contenu dans le dictionnaire de synthèse du jury PE et en suivant un
|
||||
fichier modele donné
|
||||
|
||||
result: chaine unicode
|
||||
"""
|
||||
if not donnees_etudiant or not un_avis_latex: # Cas d'un template vide
|
||||
return annotationPE if annotationPE else ""
|
||||
|
||||
# Le template latex (corps + footer)
|
||||
code = un_avis_latex + "\n\n" + footer_latex
|
||||
|
||||
# Recherche des tags dans le fichier
|
||||
tags_latex = get_tags_latex(code)
|
||||
if DEBUG:
|
||||
log("Les tags" + str(tags_latex))
|
||||
|
||||
# Interprète et remplace chaque tags latex par les données numériques de l'étudiant (y compris les
|
||||
# tags "macros" tels que parcourstimeline
|
||||
for tag_latex in tags_latex:
|
||||
# les tags numériques
|
||||
valeur = DONNEE_MANQUANTE
|
||||
|
||||
if ":" in tag_latex:
|
||||
(aggregat, groupe, tag_scodoc, champ) = interprete_tag_latex(tag_latex)
|
||||
valeur = str_from_syntheseJury(
|
||||
donnees_etudiant, aggregat, groupe, tag_scodoc, champ
|
||||
)
|
||||
|
||||
# La macro parcourstimeline
|
||||
elif tag_latex == "parcourstimeline":
|
||||
valeur = comp_latex_parcourstimeline(
|
||||
donnees_etudiant, donnees_etudiant["promo"]
|
||||
)
|
||||
|
||||
# Le tag annotationPE
|
||||
elif tag_latex == "annotation":
|
||||
valeur = annotationPE
|
||||
|
||||
# Le tag bilanParTag
|
||||
elif tag_latex == "bilanParTag":
|
||||
valeur = get_bilanParTag(donnees_etudiant)
|
||||
|
||||
# Les tags "simples": par ex. nom, prenom, civilite, ...
|
||||
else:
|
||||
if tag_latex in donnees_etudiant:
|
||||
valeur = donnees_etudiant[tag_latex]
|
||||
elif tag_latex in prefs: # les champs **NomResponsablePE**, ...
|
||||
valeur = pe_tools.escape_for_latex(prefs[tag_latex])
|
||||
|
||||
# Vérification des pb d'encodage (debug)
|
||||
# assert isinstance(tag_latex, unicode)
|
||||
# assert isinstance(valeur, unicode)
|
||||
|
||||
# Substitution
|
||||
code = code.replace("**" + tag_latex + "**", valeur)
|
||||
return code
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_annotation_PE(etudid, tag_annotation_pe):
|
||||
"""Renvoie l'annotation PE dans la liste de ces annotations ;
|
||||
Cette annotation est reconnue par la présence d'un tag **PE**
|
||||
(cf. .get_preferences -> pe_tag_annotation_avis_latex).
|
||||
|
||||
Result: chaine unicode
|
||||
"""
|
||||
if tag_annotation_pe:
|
||||
cnx = ndb.GetDBConnexion()
|
||||
annotations = sco_etud.etud_annotations_list(
|
||||
cnx, args={"etudid": etudid}
|
||||
) # Les annotations de l'étudiant
|
||||
annotationsPE = []
|
||||
|
||||
exp = re.compile(r"^" + tag_annotation_pe)
|
||||
|
||||
for a in annotations:
|
||||
commentaire = scu.unescape_html(a["comment"])
|
||||
if exp.match(commentaire): # tag en début de commentaire ?
|
||||
a["comment_u"] = commentaire # unicode, HTML non quoté
|
||||
annotationsPE.append(
|
||||
a
|
||||
) # sauvegarde l'annotation si elle contient le tag
|
||||
|
||||
if annotationsPE: # Si des annotations existent, prend la plus récente
|
||||
annotationPE = sorted(annotationsPE, key=lambda a: a["date"], reverse=True)[
|
||||
0
|
||||
]["comment_u"]
|
||||
|
||||
annotationPE = exp.sub(
|
||||
"", annotationPE
|
||||
) # Suppression du tag d'annotation PE
|
||||
annotationPE = annotationPE.replace("\r", "") # Suppression des \r
|
||||
annotationPE = annotationPE.replace(
|
||||
"<br>", "\n\n"
|
||||
) # Interprète les retours chariots html
|
||||
return annotationPE
|
||||
return "" # pas d'annotations
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, champ):
|
||||
"""Extrait du dictionnaire de synthèse du juryPE pour un étudiant donnée,
|
||||
une valeur indiquée par un champ ;
|
||||
si champ est une liste, renvoie la liste des valeurs extraites.
|
||||
|
||||
Result: chaine unicode ou liste de chaines unicode
|
||||
"""
|
||||
|
||||
if isinstance(champ, list):
|
||||
return [
|
||||
str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, chp)
|
||||
for chp in champ
|
||||
]
|
||||
else: # champ = str à priori
|
||||
valeur = DONNEE_MANQUANTE
|
||||
if (
|
||||
(aggregat in donnees_etudiant)
|
||||
and (groupe in donnees_etudiant[aggregat])
|
||||
and (tag_scodoc in donnees_etudiant[aggregat][groupe])
|
||||
):
|
||||
donnees_numeriques = donnees_etudiant[aggregat][groupe][tag_scodoc]
|
||||
if champ == "rang":
|
||||
valeur = "%s/%d" % (
|
||||
donnees_numeriques[
|
||||
pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index("rang")
|
||||
],
|
||||
donnees_numeriques[
|
||||
pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index(
|
||||
"nbinscrits"
|
||||
)
|
||||
],
|
||||
)
|
||||
elif champ in pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS:
|
||||
indice_champ = pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index(
|
||||
champ
|
||||
)
|
||||
if (
|
||||
len(donnees_numeriques) > indice_champ
|
||||
and donnees_numeriques[indice_champ] != None
|
||||
):
|
||||
if isinstance(
|
||||
donnees_numeriques[indice_champ], float
|
||||
): # valeur numérique avec formattage unicode
|
||||
valeur = "%2.2f" % donnees_numeriques[indice_champ]
|
||||
else:
|
||||
valeur = "%s" % donnees_numeriques[indice_champ]
|
||||
|
||||
return valeur
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_bilanParTag(donnees_etudiant, groupe="groupe"):
|
||||
"""Renvoie le code latex d'un tableau récapitulant, pour tous les tags trouvés dans
|
||||
les données étudiants, ses résultats.
|
||||
result: chaine unicode
|
||||
"""
|
||||
|
||||
entete = [
|
||||
(
|
||||
agg,
|
||||
pe_jurype.JuryPE.PARCOURS[agg]["affichage_court"],
|
||||
pe_jurype.JuryPE.PARCOURS[agg]["ordre"],
|
||||
)
|
||||
for agg in pe_jurype.JuryPE.PARCOURS
|
||||
]
|
||||
entete = sorted(entete, key=lambda t: t[2])
|
||||
|
||||
lignes = []
|
||||
valeurs = {"note": [], "rang": []}
|
||||
for (indice_aggregat, (aggregat, intitule, _)) in enumerate(entete):
|
||||
# print("> " + aggregat)
|
||||
# listeTags = jury.get_allTagForAggregat(aggregat) # les tags de l'aggrégat
|
||||
listeTags = [
|
||||
tag for tag in donnees_etudiant[aggregat][groupe].keys() if tag != "dut"
|
||||
] #
|
||||
for tag in listeTags:
|
||||
|
||||
if tag not in lignes:
|
||||
lignes.append(tag)
|
||||
valeurs["note"].append(
|
||||
[""] * len(entete)
|
||||
) # Ajout d'une ligne de données
|
||||
valeurs["rang"].append(
|
||||
[""] * len(entete)
|
||||
) # Ajout d'une ligne de données
|
||||
indice_tag = lignes.index(tag) # l'indice de ligne du tag
|
||||
|
||||
# print(" --- " + tag + "(" + str(indice_tag) + "," + str(indice_aggregat) + ")")
|
||||
[note, rang] = str_from_syntheseJury(
|
||||
donnees_etudiant, aggregat, groupe, tag, ["note", "rang"]
|
||||
)
|
||||
valeurs["note"][indice_tag][indice_aggregat] = "" + note + ""
|
||||
valeurs["rang"][indice_tag][indice_aggregat] = (
|
||||
("\\textit{" + rang + "}") if note else ""
|
||||
) # rang masqué si pas de notes
|
||||
|
||||
code_latex = "\\begin{tabular}{|c|" + "|c" * (len(entete)) + "|}\n"
|
||||
code_latex += "\\hline \n"
|
||||
code_latex += (
|
||||
" & "
|
||||
+ " & ".join(["\\textbf{" + intitule + "}" for (agg, intitule, _) in entete])
|
||||
+ " \\\\ \n"
|
||||
)
|
||||
code_latex += "\\hline"
|
||||
code_latex += "\\hline \n"
|
||||
for (i, ligne_val) in enumerate(valeurs["note"]):
|
||||
titre = lignes[i] # règle le pb d'encodage
|
||||
code_latex += "\\textbf{" + titre + "} & " + " & ".join(ligne_val) + "\\\\ \n"
|
||||
code_latex += (
|
||||
" & "
|
||||
+ " & ".join(
|
||||
["{\\scriptsize " + clsmt + "}" for clsmt in valeurs["rang"][i]]
|
||||
)
|
||||
+ "\\\\ \n"
|
||||
)
|
||||
code_latex += "\\hline \n"
|
||||
code_latex += "\\end{tabular}"
|
||||
|
||||
return code_latex
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_avis_poursuite_par_etudiant(
|
||||
jury, etudid, template_latex, tag_annotation_pe, footer_latex, prefs
|
||||
):
|
||||
"""Renvoie un nom de fichier et le contenu de l'avis latex d'un étudiant dont l'etudid est fourni.
|
||||
result: [ chaine unicode, chaine unicode ]
|
||||
"""
|
||||
if pe_tools.PE_DEBUG:
|
||||
pe_tools.pe_print(jury.syntheseJury[etudid]["nom"] + " " + str(etudid))
|
||||
|
||||
civilite_str = jury.syntheseJury[etudid]["civilite_str"]
|
||||
nom = jury.syntheseJury[etudid]["nom"].replace(" ", "-")
|
||||
prenom = jury.syntheseJury[etudid]["prenom"].replace(" ", "-")
|
||||
|
||||
nom_fichier = scu.sanitize_filename(
|
||||
"avis_poursuite_%s_%s_%s" % (nom, prenom, etudid)
|
||||
)
|
||||
if pe_tools.PE_DEBUG:
|
||||
pe_tools.pe_print("fichier latex =" + nom_fichier, type(nom_fichier))
|
||||
|
||||
# Entete (commentaire)
|
||||
contenu_latex = (
|
||||
"%% ---- Etudiant: " + civilite_str + " " + nom + " " + prenom + "\n"
|
||||
)
|
||||
|
||||
# les annnotations
|
||||
annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe)
|
||||
if pe_tools.PE_DEBUG:
|
||||
pe_tools.pe_print(annotationPE, type(annotationPE))
|
||||
|
||||
# le LaTeX
|
||||
avis = get_code_latex_avis_etudiant(
|
||||
jury.syntheseJury[etudid], template_latex, annotationPE, footer_latex, prefs
|
||||
)
|
||||
# if pe_tools.PE_DEBUG: pe_tools.pe_print(avis, type(avis))
|
||||
contenu_latex += avis + "\n"
|
||||
|
||||
return [nom_fichier, contenu_latex]
|
||||
|
||||
|
||||
def get_templates_from_distrib(template="avis"):
|
||||
"""Récupère le template (soit un_avis.tex soit le footer.tex) à partir des fichiers mémorisés dans la distrib des avis pe (distrib local
|
||||
ou par défaut et le renvoie"""
|
||||
if template == "avis":
|
||||
pe_local_tmpl = pe_tools.PE_LOCAL_AVIS_LATEX_TMPL
|
||||
pe_default_tmpl = pe_tools.PE_DEFAULT_AVIS_LATEX_TMPL
|
||||
elif template == "footer":
|
||||
pe_local_tmpl = pe_tools.PE_LOCAL_FOOTER_TMPL
|
||||
pe_default_tmpl = pe_tools.PE_DEFAULT_FOOTER_TMPL
|
||||
|
||||
if template in ["avis", "footer"]:
|
||||
# pas de preference pour le template: utilise fichier du serveur
|
||||
if os.path.exists(pe_local_tmpl):
|
||||
template_latex = get_code_latex_from_modele(pe_local_tmpl)
|
||||
else:
|
||||
if os.path.exists(pe_default_tmpl):
|
||||
template_latex = get_code_latex_from_modele(pe_default_tmpl)
|
||||
else:
|
||||
template_latex = "" # fallback: avis vides
|
||||
return template_latex
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def table_syntheseAnnotationPE(syntheseJury, tag_annotation_pe):
|
||||
"""Génère un fichier excel synthétisant les annotations PE telles qu'inscrites dans les fiches de chaque étudiant"""
|
||||
sT = SeqGenTable() # le fichier excel à générer
|
||||
|
||||
# Les etudids des étudiants à afficher, triés par ordre alphabétiques de nom+prénom
|
||||
donnees_tries = sorted(
|
||||
[
|
||||
(etudid, syntheseJury[etudid]["nom"] + " " + syntheseJury[etudid]["prenom"])
|
||||
for etudid in syntheseJury.keys()
|
||||
],
|
||||
key=lambda c: c[1],
|
||||
)
|
||||
etudids = [e[0] for e in donnees_tries]
|
||||
if not etudids: # Si pas d'étudiants
|
||||
T = GenTable(
|
||||
columns_ids=["pas d'étudiants"],
|
||||
rows=[],
|
||||
titles={"pas d'étudiants": "pas d'étudiants"},
|
||||
html_sortable=True,
|
||||
xls_sheet_name="dut",
|
||||
)
|
||||
sT.add_genTable("Annotation PE", T)
|
||||
return sT
|
||||
|
||||
# Si des étudiants
|
||||
maxParcours = max(
|
||||
[syntheseJury[etudid]["nbSemestres"] for etudid in etudids]
|
||||
) # le nombre de semestre le + grand
|
||||
|
||||
infos = ["civilite", "nom", "prenom", "age", "nbSemestres"]
|
||||
entete = ["etudid"]
|
||||
entete.extend(infos)
|
||||
entete.extend(["P%d" % i for i in range(1, maxParcours + 1)]) # ajout du parcours
|
||||
entete.append("Annotation PE")
|
||||
columns_ids = entete # les id et les titres de colonnes sont ici identiques
|
||||
titles = {i: i for i in columns_ids}
|
||||
|
||||
rows = []
|
||||
for (
|
||||
etudid
|
||||
) in etudids: # parcours des étudiants par ordre alphabétique des nom+prénom
|
||||
e = syntheseJury[etudid]
|
||||
# Les info générales:
|
||||
row = {
|
||||
"etudid": etudid,
|
||||
"civilite": e["civilite"],
|
||||
"nom": e["nom"],
|
||||
"prenom": e["prenom"],
|
||||
"age": e["age"],
|
||||
"nbSemestres": e["nbSemestres"],
|
||||
}
|
||||
# Les parcours: P1, P2, ...
|
||||
n = 1
|
||||
for p in e["parcours"]:
|
||||
row["P%d" % n] = p["titreannee"]
|
||||
n += 1
|
||||
|
||||
# L'annotation PE
|
||||
annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe)
|
||||
row["Annotation PE"] = annotationPE if annotationPE else ""
|
||||
rows.append(row)
|
||||
|
||||
T = GenTable(
|
||||
columns_ids=columns_ids,
|
||||
rows=rows,
|
||||
titles=titles,
|
||||
html_sortable=True,
|
||||
xls_sheet_name="Annotation PE",
|
||||
)
|
||||
sT.add_genTable("Annotation PE", T)
|
||||
return sT
|
|
@ -0,0 +1,339 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import os
|
||||
import datetime
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
import pandas as pd
|
||||
from flask import g
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
from app.models import FormSemestre
|
||||
from app.pe.rcss.pe_rcs import TYPES_RCS
|
||||
from app.scodoc import sco_formsemestre
|
||||
from app.scodoc.sco_logos import find_logo
|
||||
|
||||
|
||||
# Generated LaTeX files are encoded as:
|
||||
PE_LATEX_ENCODING = "utf-8"
|
||||
|
||||
# /opt/scodoc/tools/doc_poursuites_etudes
|
||||
REP_DEFAULT_AVIS = os.path.join(scu.SCO_TOOLS_DIR, "doc_poursuites_etudes/")
|
||||
REP_LOCAL_AVIS = os.path.join(scu.SCODOC_CFG_DIR, "doc_poursuites_etudes/")
|
||||
|
||||
PE_DEFAULT_AVIS_LATEX_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_avis.tex"
|
||||
PE_LOCAL_AVIS_LATEX_TMPL = REP_LOCAL_AVIS + "local/modeles/un_avis.tex"
|
||||
PE_DEFAULT_FOOTER_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_footer.tex"
|
||||
PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex"
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
|
||||
"""
|
||||
Descriptif d'un parcours classique BUT
|
||||
|
||||
TODO:: A améliorer si BUT en moins de 6 semestres
|
||||
"""
|
||||
|
||||
NBRE_SEMESTRES_DIPLOMANT = 6
|
||||
AGGREGAT_DIPLOMANT = (
|
||||
"6S" # aggrégat correspondant à la totalité des notes pour le diplôme
|
||||
)
|
||||
TOUS_LES_SEMESTRES = TYPES_RCS[AGGREGAT_DIPLOMANT]["aggregat"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def calcul_age(born: datetime.date) -> int:
|
||||
"""Calcule l'age connaissant la date de naissance ``born``. (L'age est calculé
|
||||
à partir de l'horloge système).
|
||||
|
||||
Args:
|
||||
born: La date de naissance
|
||||
|
||||
Return:
|
||||
L'age (au regard de la date actuelle)
|
||||
"""
|
||||
if not born or not isinstance(born, datetime.date):
|
||||
return None
|
||||
|
||||
today = datetime.date.today()
|
||||
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
|
||||
|
||||
# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes
|
||||
def remove_accents(input_unicode_str: str) -> bytes:
|
||||
"""Supprime les accents d'une chaine unicode"""
|
||||
nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
|
||||
only_ascii = nfkd_form.encode("ASCII", "ignore")
|
||||
return only_ascii
|
||||
|
||||
|
||||
def escape_for_latex(s):
|
||||
"""Protège les caractères pour inclusion dans du source LaTeX"""
|
||||
if not s:
|
||||
return ""
|
||||
conv = {
|
||||
"&": r"\&",
|
||||
"%": r"\%",
|
||||
"$": r"\$",
|
||||
"#": r"\#",
|
||||
"_": r"\_",
|
||||
"{": r"\{",
|
||||
"}": r"\}",
|
||||
"~": r"\textasciitilde{}",
|
||||
"^": r"\^{}",
|
||||
"\\": r"\textbackslash{}",
|
||||
"<": r"\textless ",
|
||||
">": r"\textgreater ",
|
||||
}
|
||||
exp = re.compile(
|
||||
"|".join(
|
||||
re.escape(key)
|
||||
for key in sorted(list(conv.keys()), key=lambda item: -len(item))
|
||||
)
|
||||
)
|
||||
return exp.sub(lambda match: conv[match.group()], s)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def list_directory_filenames(path: str) -> list[str]:
|
||||
"""List of regular filenames (paths) in a directory (recursive)
|
||||
Excludes files and directories begining with .
|
||||
"""
|
||||
paths = []
|
||||
for root, dirs, files in os.walk(path, topdown=True):
|
||||
dirs[:] = [d for d in dirs if d[0] != "."]
|
||||
paths += [os.path.join(root, fn) for fn in files if fn[0] != "."]
|
||||
return paths
|
||||
|
||||
|
||||
def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
|
||||
"""Read pathname server file and add content to zip under path_in_zip"""
|
||||
rooted_path_in_zip = os.path.join(ziproot, path_in_zip)
|
||||
zipfile.write(filename=pathname, arcname=rooted_path_in_zip)
|
||||
# data = open(pathname).read()
|
||||
# zipfile.writestr(rooted_path_in_zip, data)
|
||||
|
||||
|
||||
def add_refs_to_register(register, directory):
|
||||
"""Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme
|
||||
filename => pathname
|
||||
"""
|
||||
length = len(directory)
|
||||
for pathname in list_directory_filenames(directory):
|
||||
filename = pathname[length + 1 :]
|
||||
register[filename] = pathname
|
||||
|
||||
|
||||
def add_pe_stuff_to_zip(zipfile, ziproot):
|
||||
"""Add auxiliary files to (already opened) zip
|
||||
Put all local files found under config/doc_poursuites_etudes/local
|
||||
and config/doc_poursuites_etudes/distrib
|
||||
If a file is present in both subtrees, take the one in local.
|
||||
|
||||
Also copy logos
|
||||
"""
|
||||
register = {}
|
||||
# first add standard (distrib references)
|
||||
distrib_dir = os.path.join(REP_DEFAULT_AVIS, "distrib")
|
||||
add_refs_to_register(register=register, directory=distrib_dir)
|
||||
# then add local references (some oh them may overwrite distrib refs)
|
||||
local_dir = os.path.join(REP_LOCAL_AVIS, "local")
|
||||
add_refs_to_register(register=register, directory=local_dir)
|
||||
# at this point register contains all refs (filename, pathname) to be saved
|
||||
for filename, pathname in register.items():
|
||||
add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename)
|
||||
|
||||
# Logos: (add to logos/ directory in zip)
|
||||
logos_names = ["header", "footer"]
|
||||
for name in logos_names:
|
||||
logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id)
|
||||
if logo is not None:
|
||||
add_local_file_to_zip(
|
||||
zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def get_annee_diplome_semestre(
|
||||
sem_base: FormSemestre | dict, nbre_sem_formation: int = 6
|
||||
) -> int:
|
||||
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT
|
||||
à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du
|
||||
semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y
|
||||
sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir).
|
||||
|
||||
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4,
|
||||
S6 pour des semestres décalés)
|
||||
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie
|
||||
d'année universitaire.
|
||||
|
||||
Par exemple :
|
||||
|
||||
* S5 débutant en 2025 finissant en 2026 : diplome en 2026
|
||||
* S3 debutant en 2025 et finissant en 2026 : diplome en 2027
|
||||
|
||||
La fonction est adaptée au cas des semestres décalés.
|
||||
|
||||
Par exemple :
|
||||
|
||||
* S5 décalé débutant en 2025 et finissant en 2025 : diplome en 2026
|
||||
* S3 décalé débutant en 2025 et finissant en 2025 : diplome en 2027
|
||||
|
||||
Args:
|
||||
sem_base: Le semestre à partir duquel est prédit l'année de diplomation, soit :
|
||||
|
||||
* un ``FormSemestre`` (Scodoc9)
|
||||
* un dict (format compatible avec Scodoc7)
|
||||
|
||||
nbre_sem_formation: Le nombre de semestre prévu dans la formation (par défaut 6 pour un BUT)
|
||||
"""
|
||||
|
||||
if isinstance(sem_base, FormSemestre):
|
||||
sem_id = sem_base.semestre_id
|
||||
annee_fin = sem_base.date_fin.year
|
||||
annee_debut = sem_base.date_debut.year
|
||||
else: # sem_base est un dictionnaire (Scodoc 7)
|
||||
sem_id = sem_base["semestre_id"]
|
||||
annee_fin = int(sem_base["annee_fin"])
|
||||
annee_debut = int(sem_base["annee_debut"])
|
||||
if (
|
||||
1 <= sem_id <= nbre_sem_formation
|
||||
): # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ??
|
||||
nb_sem_restants = (
|
||||
nbre_sem_formation - sem_id
|
||||
) # nombre de semestres restant avant diplome
|
||||
nb_annees_restantes = (
|
||||
nb_sem_restants // 2
|
||||
) # nombre d'annees restant avant diplome
|
||||
# Flag permettant d'activer ou désactiver un increment
|
||||
# à prendre en compte en cas de semestre décalé
|
||||
# avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon
|
||||
delta = annee_fin - annee_debut
|
||||
decalage = nb_sem_restants % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
|
||||
increment = decalage * (1 - delta)
|
||||
return annee_fin + nb_annees_restantes + increment
|
||||
|
||||
|
||||
def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
|
||||
"""Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``.
|
||||
|
||||
**Définition** : Un co-semestre est un semestre :
|
||||
|
||||
* dont l'année de diplômation prédite (sans redoublement) est la même
|
||||
* dont la formation est la même (optionnel)
|
||||
* qui a des étudiants inscrits
|
||||
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres
|
||||
"""
|
||||
tous_les_sems = (
|
||||
sco_formsemestre.do_formsemestre_list()
|
||||
) # tous les semestres memorisés dans scodoc
|
||||
|
||||
cosemestres_fids = {
|
||||
sem["id"]
|
||||
for sem in tous_les_sems
|
||||
if get_annee_diplome_semestre(sem) == annee_diplome
|
||||
}
|
||||
|
||||
cosemestres = {}
|
||||
for fid in cosemestres_fids:
|
||||
cosem = FormSemestre.get_formsemestre(fid)
|
||||
if len(cosem.etuds_inscriptions) > 0:
|
||||
cosemestres[fid] = cosem
|
||||
|
||||
return cosemestres
|
||||
|
||||
|
||||
def tri_semestres_par_rang(cosemestres: dict[int, FormSemestre]):
|
||||
"""Partant d'un dictionnaire de cosemestres, les tri par rang (semestre_id) dans un
|
||||
dictionnaire {rang: [liste des semestres du dit rang]}"""
|
||||
cosemestres_tries = {}
|
||||
for sem in cosemestres.values():
|
||||
cosemestres_tries[sem.semestre_id] = cosemestres_tries.get(
|
||||
sem.semestre_id, []
|
||||
) + [sem]
|
||||
return cosemestres_tries
|
||||
|
||||
|
||||
def find_index_and_columns_communs(
|
||||
df1: pd.DataFrame, df2: pd.DataFrame
|
||||
) -> (list, list):
|
||||
"""Partant de 2 DataFrames ``df1`` et ``df2``, renvoie les indices de lignes
|
||||
et de colonnes, communes aux 2 dataframes
|
||||
|
||||
Args:
|
||||
df1: Un dataFrame
|
||||
df2: Un dataFrame
|
||||
Returns:
|
||||
Le tuple formé par la liste des indices de lignes communs et la liste des indices
|
||||
de colonnes communes entre les 2 dataFrames
|
||||
"""
|
||||
indices1 = df1.index
|
||||
indices2 = df2.index
|
||||
indices_communs = list(df1.index.intersection(df2.index))
|
||||
colonnes1 = df1.columns
|
||||
colonnes2 = df2.columns
|
||||
colonnes_communes = list(set(colonnes1) & set(colonnes2))
|
||||
return indices_communs, colonnes_communes
|
||||
|
||||
|
||||
def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
|
||||
"""Renvoie le dernier semestre en **date de fin** d'un dictionnaire
|
||||
de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
|
||||
|
||||
Args:
|
||||
semestres: Un dictionnaire de semestres
|
||||
|
||||
Return:
|
||||
Le FormSemestre du semestre le plus récent
|
||||
"""
|
||||
if semestres:
|
||||
fid_dernier_semestre = list(semestres.keys())[0]
|
||||
dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
|
||||
for fid in semestres:
|
||||
if semestres[fid].date_fin > dernier_semestre.date_fin:
|
||||
dernier_semestre = semestres[fid]
|
||||
return dernier_semestre
|
||||
return None
|
|
@ -0,0 +1,653 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. c All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on 17/01/2024
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from app import ScoValueError
|
||||
from app.models import FormSemestre, Identite, Formation
|
||||
from app.pe import pe_comp, pe_affichage
|
||||
from app.pe.rcss import pe_rcs
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.comp.res_sem import load_formsemestre_results
|
||||
|
||||
|
||||
class EtudiantsJuryPE:
|
||||
"""Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE"""
|
||||
|
||||
def __init__(self, annee_diplome: int):
|
||||
"""
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
"""
|
||||
self.annee_diplome = annee_diplome
|
||||
"""L'année du diplôme"""
|
||||
|
||||
self.identites: dict[int:Identite] = {} # ex. ETUDINFO_DICT
|
||||
"""Les identités des étudiants traités pour le jury"""
|
||||
|
||||
self.cursus: dict[int:dict] = {}
|
||||
"""Les cursus (semestres suivis, abandons) des étudiants"""
|
||||
|
||||
self.trajectoires: dict[int:dict] = {}
|
||||
"""Les trajectoires (regroupement cohérents de semestres) suivis par les étudiants"""
|
||||
|
||||
self.semXs: dict[int:dict] = {}
|
||||
"""Les semXs (RCS de type Sx) suivis par chaque étudiant"""
|
||||
|
||||
self.rcsemXs: dict[int:dict] = {}
|
||||
"""Les RC de SemXs (RCS de type Sx, xA, xS) suivis par chaque étudiant"""
|
||||
|
||||
self.etudiants_diplomes = {}
|
||||
"""Les identités des étudiants à considérer au jury (ceux qui seront effectivement
|
||||
diplômés)"""
|
||||
|
||||
self.diplomes_ids = {}
|
||||
"""Les etudids des étudiants diplômés"""
|
||||
|
||||
self.etudiants_ids = {}
|
||||
"""Les etudids des étudiants dont il faut calculer les moyennes/classements
|
||||
(même si d'éventuels abandons).
|
||||
Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi
|
||||
d'autres ayant été réorientés ou ayant abandonnés)"""
|
||||
|
||||
self.cosemestres: dict[int, FormSemestre] = None
|
||||
"Les cosemestres donnant lieu à même année de diplome"
|
||||
|
||||
self.abandons = {}
|
||||
"""Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)"""
|
||||
self.abandons_ids = {}
|
||||
"""Les etudids des étudiants redoublants/réorientés"""
|
||||
|
||||
def find_etudiants(self):
|
||||
"""Liste des étudiants à prendre en compte dans le jury PE, en les recherchant
|
||||
de manière automatique par rapport à leur année de diplomation ``annee_diplome``.
|
||||
|
||||
Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE.
|
||||
|
||||
*Remarque* : ex: JuryPE.get_etudiants_in_jury()
|
||||
"""
|
||||
cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome)
|
||||
self.cosemestres = cosemestres
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"1) Recherche des cosemestres -> {len(cosemestres)} trouvés", info=True
|
||||
)
|
||||
|
||||
pe_affichage.pe_print(
|
||||
"2) Liste des étudiants dans les différents cosemestres", info=True
|
||||
)
|
||||
etudiants_ids = get_etudiants_dans_semestres(cosemestres)
|
||||
pe_affichage.pe_print(
|
||||
f" => {len(etudiants_ids)} étudiants trouvés dans les cosemestres",
|
||||
info=True,
|
||||
)
|
||||
|
||||
# Analyse des parcours étudiants pour déterminer leur année effective de diplome
|
||||
# avec prise en compte des redoublements, des abandons, ....
|
||||
pe_affichage.pe_print(
|
||||
"3) Analyse des parcours individuels des étudiants", info=True
|
||||
)
|
||||
|
||||
# Ajoute une liste d'étudiants
|
||||
self.add_etudiants(etudiants_ids)
|
||||
|
||||
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
|
||||
self.etudiants_diplomes = self.get_etudiants_diplomes()
|
||||
self.diplomes_ids = set(self.etudiants_diplomes.keys())
|
||||
self.etudiants_ids = set(self.identites.keys())
|
||||
|
||||
# Les abandons (pour debug)
|
||||
self.abandons = self.get_etudiants_redoublants_ou_reorientes()
|
||||
# Les identités des étudiants ayant redoublés ou ayant abandonnés
|
||||
|
||||
self.abandons_ids = set(self.abandons)
|
||||
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
|
||||
|
||||
# Synthèse
|
||||
pe_affichage.pe_print(f"4) Bilan", info=True)
|
||||
pe_affichage.pe_print(
|
||||
f"--> {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}",
|
||||
info=True,
|
||||
)
|
||||
nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
|
||||
assert nbre_abandons == len(self.abandons_ids)
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"--> {nbre_abandons} étudiants traités mais non diplômés (redoublement, réorientation, abandon)"
|
||||
)
|
||||
|
||||
def add_etudiants(self, etudiants_ids):
|
||||
"""Ajoute une liste d'étudiants aux données du jury"""
|
||||
nbre_etudiants_ajoutes = 0
|
||||
for etudid in etudiants_ids:
|
||||
if etudid not in self.identites:
|
||||
nbre_etudiants_ajoutes += 1
|
||||
|
||||
# L'identité de l'étudiant
|
||||
self.identites[etudid] = Identite.get_etud(etudid)
|
||||
|
||||
# Analyse son cursus
|
||||
self.analyse_etat_etudiant(etudid, self.cosemestres)
|
||||
|
||||
# Analyse son parcours pour atteindre chaque semestre de la formation
|
||||
self.structure_cursus_etudiant(etudid)
|
||||
self.etudiants_ids = set(self.identites.keys())
|
||||
return nbre_etudiants_ajoutes
|
||||
|
||||
def get_etudiants_diplomes(self) -> dict[int, Identite]:
|
||||
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
|
||||
qui vont être à traiter au jury PE pour
|
||||
l'année de diplômation donnée et n'ayant ni été réorienté, ni abandonné.
|
||||
|
||||
|
||||
Returns:
|
||||
Un dictionnaire `{etudid: Identite(etudid)}`
|
||||
"""
|
||||
etudids = [
|
||||
etudid
|
||||
for etudid, cursus_etud in self.cursus.items()
|
||||
if cursus_etud["diplome"] == self.annee_diplome
|
||||
and cursus_etud["abandon"] is False
|
||||
]
|
||||
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
|
||||
return etudiants
|
||||
|
||||
def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]:
|
||||
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
|
||||
dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront
|
||||
pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire `{etudid: Identite(etudid)}`
|
||||
"""
|
||||
etudids = [
|
||||
etudid
|
||||
for etudid, cursus_etud in self.cursus.items()
|
||||
if cursus_etud["diplome"] != self.annee_diplome
|
||||
or cursus_etud["abandon"] is True
|
||||
]
|
||||
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
|
||||
return etudiants
|
||||
|
||||
def analyse_etat_etudiant(self, etudid: int, cosemestres: dict[int, FormSemestre]):
|
||||
"""Analyse le cursus d'un étudiant pouvant être :
|
||||
|
||||
* l'un de ceux sur lesquels le jury va statuer (année de diplômation du jury considéré)
|
||||
* un étudiant qui ne sera pas considéré dans le jury mais qui a participé dans sa scolarité
|
||||
à un (ou plusieurs) semestres communs aux étudiants du jury (et impactera les classements)
|
||||
|
||||
L'analyse consiste :
|
||||
|
||||
* à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
|
||||
avec son nom, prénom, etc...
|
||||
* à analyser son parcours, pour déterminer s'il a démissionné, redoublé (autre année de diplôme)
|
||||
ou a abandonné l'IUT en cours de route (cf. clé abandon). Un étudiant est considéré
|
||||
en abandon si connaissant son dernier semestre (par ex. un S3) il n'est pas systématiquement
|
||||
inscrit à l'un des S4, S5 ou S6 existants dans les cosemestres.
|
||||
|
||||
|
||||
Args:
|
||||
etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
|
||||
cosemestres: Dictionnaire {fid: Formsemestre(fid)} donnant accès aux cosemestres
|
||||
de même année de diplomation
|
||||
"""
|
||||
identite = Identite.get_etud(etudid)
|
||||
|
||||
# Le cursus global de l'étudiant (restreint aux semestres APC)
|
||||
formsemestres = identite.get_formsemestres()
|
||||
|
||||
semestres_etudiant = {
|
||||
formsemestre.formsemestre_id: formsemestre
|
||||
for formsemestre in formsemestres
|
||||
if formsemestre.formation.is_apc()
|
||||
}
|
||||
|
||||
# Le parcours final
|
||||
parcour = formsemestres[0].etuds_inscriptions[etudid].parcour
|
||||
if parcour:
|
||||
libelle = parcour.libelle
|
||||
else:
|
||||
libelle = None
|
||||
|
||||
self.cursus[etudid] = {
|
||||
"etudid": etudid, # les infos sur l'étudiant
|
||||
"etat_civil": identite.etat_civil, # Ajout à la table jury
|
||||
"nom": identite.nom,
|
||||
"entree": formsemestres[-1].date_debut.year, # La date d'entrée à l'IUT
|
||||
"parcours": libelle, # Le parcours final
|
||||
"diplome": get_annee_diplome(
|
||||
identite
|
||||
), # Le date prévisionnelle de son diplôme
|
||||
"formsemestres": semestres_etudiant, # les semestres de l'étudiant
|
||||
"nb_semestres": len(
|
||||
semestres_etudiant
|
||||
), # le nombre de semestres de l'étudiant
|
||||
"abandon": False, # va être traité en dessous
|
||||
}
|
||||
|
||||
# Si l'étudiant est succeptible d'être diplomé
|
||||
if self.cursus[etudid]["diplome"] == self.annee_diplome:
|
||||
# Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
|
||||
dernier_semes_etudiant = formsemestres[0]
|
||||
res = load_formsemestre_results(dernier_semes_etudiant)
|
||||
etud_etat = res.get_etud_etat(etudid)
|
||||
if etud_etat == scu.DEMISSION:
|
||||
self.cursus[etudid]["abandon"] = True
|
||||
else:
|
||||
# Est-il réorienté ou a-t-il arrêté (volontairement) sa formation ?
|
||||
self.cursus[etudid]["abandon"] = arret_de_formation(
|
||||
identite, cosemestres
|
||||
)
|
||||
|
||||
# Initialise ses trajectoires/SemX/RCSemX
|
||||
self.trajectoires[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
|
||||
self.semXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_SEMESTRES}
|
||||
self.rcsemXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
|
||||
|
||||
def structure_cursus_etudiant(self, etudid: int):
|
||||
"""Structure les informations sur les semestres suivis par un
|
||||
étudiant, pour identifier les semestres qui seront pris en compte lors de ses calculs
|
||||
de moyennes PE.
|
||||
|
||||
Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
|
||||
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
|
||||
Ce semestre influera les interclassements par semestre dans la promo.
|
||||
"""
|
||||
semestres_significatifs = get_semestres_significatifs(
|
||||
self.cursus[etudid]["formsemestres"], self.annee_diplome
|
||||
)
|
||||
|
||||
# Tri des semestres par numéro de semestre
|
||||
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
|
||||
# les semestres de n°i de l'étudiant:
|
||||
semestres_i = {
|
||||
fid: sem_sig
|
||||
for fid, sem_sig in semestres_significatifs.items()
|
||||
if sem_sig.semestre_id == i
|
||||
}
|
||||
self.cursus[etudid][f"S{i}"] = semestres_i
|
||||
|
||||
def get_formsemestres_finals_des_rcs(self, nom_rcs: str) -> dict[int, FormSemestre]:
|
||||
"""Pour un nom de RCS donné, ensemble des formsemestres finals possibles
|
||||
pour les RCS. Par ex. un RCS '3S' incluant S1+S2+S3 a pour semestre final un S3.
|
||||
Les formsemestres finals obtenus traduisent :
|
||||
|
||||
* les différents parcours des étudiants liés par exemple au choix de modalité
|
||||
(par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
|
||||
formsemestre_id du S3 FI et du S3 UFA.
|
||||
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant
|
||||
redoublé sa 2ème année :
|
||||
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en
|
||||
renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
|
||||
|
||||
Args:
|
||||
nom_rcs: Le nom du RCS (parmi Sx, xA, xS)
|
||||
|
||||
Returns:
|
||||
Un dictionnaire ``{fid: FormSemestre(fid)}``
|
||||
"""
|
||||
formsemestres_terminaux = {}
|
||||
for trajectoire_aggr in self.cursus.values():
|
||||
trajectoire = trajectoire_aggr[nom_rcs]
|
||||
if trajectoire:
|
||||
# Le semestre terminal de l'étudiant de l'aggrégat
|
||||
fid = trajectoire.formsemestre_final.formsemestre_id
|
||||
formsemestres_terminaux[fid] = trajectoire.formsemestre_final
|
||||
return formsemestres_terminaux
|
||||
|
||||
def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
|
||||
"""Partant d'un ensemble d'étudiants,
|
||||
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
|
||||
|
||||
Args:
|
||||
etudids: Liste d'étudid d'étudiants
|
||||
"""
|
||||
nbres_semestres = []
|
||||
for etudid in etudids:
|
||||
nbres_semestres.append(self.cursus[etudid]["nb_semestres"])
|
||||
if not nbres_semestres:
|
||||
return 0
|
||||
return max(nbres_semestres)
|
||||
|
||||
def df_administratif(self, etudids: list[int]) -> pd.DataFrame:
|
||||
"""Synthétise toutes les données administratives d'un groupe
|
||||
d'étudiants fournis par les etudid dans un dataFrame
|
||||
|
||||
Args:
|
||||
etudids: La liste des étudiants à prendre en compte
|
||||
"""
|
||||
|
||||
etudids = list(etudids)
|
||||
|
||||
# Récupération des données des étudiants
|
||||
administratif = {}
|
||||
nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids)
|
||||
|
||||
for etudid in etudids:
|
||||
etudiant = self.identites[etudid]
|
||||
cursus = self.cursus[etudid]
|
||||
formsemestres = cursus["formsemestres"]
|
||||
parcours = cursus["parcours"]
|
||||
if not parcours:
|
||||
parcours = ""
|
||||
if cursus["diplome"]:
|
||||
diplome = cursus["diplome"]
|
||||
else:
|
||||
diplome = "indéterminé"
|
||||
|
||||
administratif[etudid] = {
|
||||
"etudid": etudiant.id,
|
||||
"INE": etudiant.code_ine or "",
|
||||
"NIP": etudiant.code_nip or "",
|
||||
"Nom": etudiant.nom,
|
||||
"Prenom": etudiant.prenom,
|
||||
"Civilite": etudiant.civilite_str,
|
||||
"Age": pe_comp.calcul_age(etudiant.date_naissance),
|
||||
"Parcours": parcours,
|
||||
"Date entree": cursus["entree"],
|
||||
"Date diplome": diplome,
|
||||
"Nb semestres": len(formsemestres),
|
||||
}
|
||||
|
||||
# Ajout des noms de semestres parcourus
|
||||
etapes = etapes_du_cursus(formsemestres, nbre_semestres_max)
|
||||
administratif[etudid] |= etapes
|
||||
|
||||
# Construction du dataframe
|
||||
df = pd.DataFrame.from_dict(administratif, orient="index")
|
||||
|
||||
# Tri par nom/prénom
|
||||
df.sort_values(by=["Nom", "Prenom"], inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
def get_semestres_significatifs(formsemestres, annee_diplome):
|
||||
"""Partant d'un ensemble de semestre, renvoie les semestres qui amèneraient les étudiants
|
||||
à être diplômé à l'année visée, y compris s'ils n'avaient pas redoublé et seraient donc
|
||||
diplômé plus tard.
|
||||
|
||||
De fait, supprime les semestres qui conduisent à une diplomation postérieure
|
||||
à celle visée.
|
||||
|
||||
Args:
|
||||
formsemestres: une liste de formsemestres
|
||||
annee_diplome: l'année du diplôme visée
|
||||
|
||||
Returns:
|
||||
Un dictionnaire ``{fid: FormSemestre(fid)}`` dans lequel les semestres
|
||||
amènent à une diplômation antérieur à celle de la diplômation visée par le jury
|
||||
"""
|
||||
# semestres_etudiant = self.cursus[etudid]["formsemestres"]
|
||||
semestres_significatifs = {}
|
||||
for fid in formsemestres:
|
||||
semestre = formsemestres[fid]
|
||||
if pe_comp.get_annee_diplome_semestre(semestre) <= annee_diplome:
|
||||
semestres_significatifs[fid] = semestre
|
||||
return semestres_significatifs
|
||||
|
||||
|
||||
def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
|
||||
"""Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``)
|
||||
inscrits à l'un des semestres de la liste de ``semestres``.
|
||||
|
||||
Args:
|
||||
semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un
|
||||
ensemble d'identifiant de semestres
|
||||
|
||||
Returns:
|
||||
Un ensemble d``etudid``
|
||||
"""
|
||||
|
||||
etudiants_ids = set()
|
||||
for sem in semestres.values(): # pour chacun des semestres de la liste
|
||||
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
|
||||
|
||||
pe_affichage.pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants")
|
||||
etudiants_ids = (
|
||||
etudiants_ids | etudiants_du_sem
|
||||
) # incluant la suppression des doublons
|
||||
|
||||
return etudiants_ids
|
||||
|
||||
|
||||
def get_annee_diplome(etud: Identite) -> int | None:
|
||||
"""L'année de diplôme prévue d'un étudiant en fonction de ses semestres
|
||||
d'inscription (pour un BUT).
|
||||
|
||||
Args:
|
||||
identite: L'identité d'un étudiant
|
||||
|
||||
Returns:
|
||||
L'année prévue de sa diplômation, ou None si aucun semestre
|
||||
"""
|
||||
formsemestres_apc = get_semestres_apc(etud)
|
||||
|
||||
if formsemestres_apc:
|
||||
dates_possibles_diplome = []
|
||||
# Années de diplômation prédites en fonction des semestres
|
||||
# (d'une formation APC) d'un étudiant
|
||||
for sem_base in formsemestres_apc:
|
||||
annee = pe_comp.get_annee_diplome_semestre(sem_base)
|
||||
if annee:
|
||||
dates_possibles_diplome.append(annee)
|
||||
if dates_possibles_diplome:
|
||||
return max(dates_possibles_diplome)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_semestres_apc(identite: Identite) -> list:
|
||||
"""Liste des semestres d'un étudiant qui correspondent à une formation APC.
|
||||
|
||||
Args:
|
||||
identite: L'identité d'un étudiant
|
||||
|
||||
Returns:
|
||||
Liste de ``FormSemestre`` correspondant à une formation APC
|
||||
"""
|
||||
semestres = identite.get_formsemestres()
|
||||
semestres_apc = []
|
||||
for sem in semestres:
|
||||
if sem.formation.is_apc():
|
||||
semestres_apc.append(sem)
|
||||
return semestres_apc
|
||||
|
||||
|
||||
def arret_de_formation(etud: Identite, cosemestres: dict[int, FormSemestre]) -> bool:
|
||||
"""Détermine si un étudiant a arrêté sa formation (volontairement ou non). Il peut s'agir :
|
||||
|
||||
* d'une réorientation à l'initiative du jury de semestre ou d'une démission
|
||||
(on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
|
||||
des résultats du jury renseigné dans la BDD, mais pas nécessaire ici)
|
||||
|
||||
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour
|
||||
autant avoir été indiqué NAR ou DEM).
|
||||
|
||||
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
|
||||
dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
|
||||
connu dans Scodoc. Par "derniers" cosemestres, est fait le choix d'analyser tous les cosemestres
|
||||
de rang/semestre_id supérieur (et donc de dates) au dernier semestre dans lequel il a été inscrit.
|
||||
|
||||
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
|
||||
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
|
||||
l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
|
||||
parti à l'étranger et là, pas de notes.
|
||||
TODO:: Cas de l'étranger, à coder/tester
|
||||
|
||||
**Attention** : Cela suppose que toutes les instances d'un semestre donné
|
||||
(par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
|
||||
étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
|
||||
TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
|
||||
|
||||
Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
|
||||
regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre :
|
||||
* dont les dates sont postérieures (en terme de date de début)
|
||||
* de n° au moins égal à celui de son dernier semestre valide (S5 -> S5 ou S5 -> S6)
|
||||
dans lequel il aurait pu s'inscrire mais ne l'a pas fait.
|
||||
|
||||
Args:
|
||||
etud: L'identité d'un étudiant
|
||||
cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury
|
||||
|
||||
Returns:
|
||||
Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
|
||||
|
||||
TODO:: A reprendre pour le cas des étudiants à l'étranger
|
||||
"""
|
||||
# Les semestres APC de l'étudiant
|
||||
semestres = get_semestres_apc(etud)
|
||||
semestres_apc = {sem.semestre_id: sem for sem in semestres}
|
||||
if not semestres_apc:
|
||||
return True
|
||||
|
||||
# Le dernier semestre de l'étudiant
|
||||
dernier_formsemestre = semestres[0]
|
||||
rang_dernier_semestre = dernier_formsemestre.semestre_id
|
||||
|
||||
# Les cosemestres de rang supérieur ou égal à celui de formsemestre, triés par rang,
|
||||
# sous la forme ``{semestre_id: [liste des comestres associé à ce semestre_id]}``
|
||||
cosemestres_tries_par_rang = pe_comp.tri_semestres_par_rang(cosemestres)
|
||||
|
||||
cosemestres_superieurs = {}
|
||||
for rang in cosemestres_tries_par_rang:
|
||||
if rang > rang_dernier_semestre:
|
||||
cosemestres_superieurs[rang] = cosemestres_tries_par_rang[rang]
|
||||
|
||||
# Si pas d'autres cosemestres postérieurs
|
||||
if not cosemestres_superieurs:
|
||||
return False
|
||||
|
||||
# Pour chaque rang de (co)semestres, y-a-il un dans lequel il est inscrit ?
|
||||
etat_inscriptions = {rang: False for rang in cosemestres_superieurs}
|
||||
for rang in etat_inscriptions:
|
||||
for sem in cosemestres_superieurs[rang]:
|
||||
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
|
||||
if etud.etudid in etudiants_du_sem:
|
||||
etat_inscriptions[rang] = True
|
||||
|
||||
# Vérifie qu'il n'y a pas de "trous" dans les rangs des cosemestres
|
||||
rangs = sorted(etat_inscriptions.keys())
|
||||
if list(rangs) != list(range(min(rangs), max(rangs) + 1)):
|
||||
difference = set(range(min(rangs), max(rangs) + 1)) - set(rangs)
|
||||
affichage = ",".join([f"S{val}" for val in difference])
|
||||
raise ScoValueError(
|
||||
f"Il manque le(s) semestre(s) {affichage} au cursus de {etud.etat_civil} ({etud.etudid})."
|
||||
)
|
||||
|
||||
# Est-il inscrit à tous les semestres de rang supérieur ? Si non, est démissionnaire
|
||||
est_demissionnaire = sum(etat_inscriptions.values()) != len(rangs)
|
||||
if est_demissionnaire:
|
||||
non_inscrit_a = [
|
||||
rang for rang in etat_inscriptions if not etat_inscriptions[rang]
|
||||
]
|
||||
affichage = ", ".join([f"S{val}" for val in non_inscrit_a])
|
||||
pe_affichage.pe_print(
|
||||
f"--> ⛔ {etud.etat_civil} ({etud.etudid}), non inscrit dans {affichage} amenant à diplômation"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(f"--> ✅ {etud.etat_civil} ({etud.etudid})")
|
||||
|
||||
return est_demissionnaire
|
||||
|
||||
|
||||
def etapes_du_cursus(
|
||||
semestres: dict[int, FormSemestre], nbre_etapes_max: int
|
||||
) -> list[str]:
|
||||
"""Partant d'un dictionnaire de semestres (qui retrace
|
||||
la scolarité d'un étudiant), liste les noms des
|
||||
semestres (en version abbrégée)
|
||||
qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
|
||||
Les noms des semestres sont renvoyés dans un dictionnaire
|
||||
``{"etape i": nom_semestre_a_etape_i}``
|
||||
avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
|
||||
le nom affiché est vide.
|
||||
|
||||
La fonction suppose la liste des semestres triées par ordre
|
||||
décroissant de date.
|
||||
|
||||
Args:
|
||||
semestres: une liste de ``FormSemestre``
|
||||
nbre_etapes_max: le nombre d'étapes max prise en compte
|
||||
|
||||
Returns:
|
||||
Une liste de nom de semestre (dans le même ordre que les ``semestres``)
|
||||
|
||||
See also:
|
||||
app.pe.pe_affichage.nom_semestre_etape
|
||||
"""
|
||||
assert len(semestres) <= nbre_etapes_max
|
||||
|
||||
noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
|
||||
noms = noms[::-1] # trie par ordre croissant
|
||||
|
||||
dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
|
||||
for i, nom in enumerate(noms): # Charge les noms de semestres
|
||||
dico[f"Etape {i+1}"] = nom
|
||||
return dico
|
||||
|
||||
|
||||
def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
|
||||
"""Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
|
||||
d'un étudiant.
|
||||
|
||||
Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
|
||||
|
||||
* 2 le numéro du semestre,
|
||||
* FI la modalité,
|
||||
* 2014-2015 les dates
|
||||
|
||||
Args:
|
||||
semestre: Un ``FormSemestre``
|
||||
avec_fid: Ajoute le n° du semestre à la description
|
||||
|
||||
Returns:
|
||||
La chaine de caractères décrivant succintement le semestre
|
||||
"""
|
||||
formation: Formation = semestre.formation
|
||||
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
|
||||
|
||||
description = [
|
||||
parcours.SESSION_NAME.capitalize(),
|
||||
str(semestre.semestre_id),
|
||||
semestre.modalite, # eg FI ou FC
|
||||
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
|
||||
]
|
||||
if avec_fid:
|
||||
description.append(f"(#{semestre.formsemestre_id})")
|
||||
|
||||
return " ".join(description)
|
|
@ -0,0 +1,863 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Ensemble des fonctions et des classes
|
||||
# permettant les calculs preliminaires (hors affichage)
|
||||
# a l'edition d'un jury de poursuites d'etudes
|
||||
# ----------------------------------------------------------
|
||||
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from zipfile import ZipFile
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import jinja2
|
||||
|
||||
from app.pe.rcss import pe_rcs
|
||||
from app.pe.moys import pe_sxtag
|
||||
|
||||
import app.pe.pe_affichage as pe_affichage
|
||||
import app.pe.pe_etudiant as pe_etudiant
|
||||
from app.pe.moys import (
|
||||
pe_tabletags,
|
||||
pe_ressemtag,
|
||||
pe_sxtag,
|
||||
pe_rcstag,
|
||||
pe_interclasstag,
|
||||
pe_moytag,
|
||||
)
|
||||
import app.pe.pe_rcss_jury as pe_rcss_jury
|
||||
from app.scodoc.sco_utils import *
|
||||
|
||||
|
||||
class JuryPE(object):
|
||||
"""
|
||||
Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
|
||||
d'une année de diplôme. De ce semestre est déduit :
|
||||
1. l'année d'obtention du DUT,
|
||||
2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
|
||||
|
||||
Les options sont :
|
||||
``
|
||||
options = {
|
||||
"cohorte_restreinte": False,
|
||||
"moyennes_tags": True,
|
||||
"moyennes_ue_res_sae": True,
|
||||
"moyennes_ues_rcues": True,
|
||||
"min_max_moy": False,
|
||||
"synthese_individuelle_etud": False,
|
||||
}
|
||||
|
||||
Args:
|
||||
diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
diplome: int,
|
||||
formsemestre_id_base,
|
||||
options={
|
||||
"moyennes_tags": True,
|
||||
"moyennes_ue_res_sae": True,
|
||||
"moyennes_ues_rcues": True,
|
||||
"min_max_moy": False,
|
||||
"publipostage": False,
|
||||
},
|
||||
):
|
||||
pe_affichage.pe_start_log()
|
||||
self.diplome = diplome
|
||||
"L'année du diplome"
|
||||
|
||||
self.formsemestre_id_base = formsemestre_id_base
|
||||
"""L'identifiant du formsemestre ayant servi à lancer le jury"""
|
||||
|
||||
self.nom_export_zip = f"Jury_PE_{self.diplome}"
|
||||
"Nom du zip où ranger les fichiers générés"
|
||||
|
||||
# Les options
|
||||
self.options = options
|
||||
"""Options de configuration (cf. pe_sem_recap)"""
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n",
|
||||
info=True,
|
||||
)
|
||||
|
||||
pe_affichage.pe_print("Options", info=True)
|
||||
for cle, val in self.options.items():
|
||||
pe_affichage.pe_print(f" > {cle} -> {val}", info=True)
|
||||
|
||||
# Chargement des étudiants à prendre en compte dans le jury
|
||||
pe_affichage.pe_print(
|
||||
f"""***********************************************************"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
f"""*** Recherche des étudiants diplômés 🎓 en {self.diplome}""", info=True
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
f"""***********************************************************"""
|
||||
)
|
||||
|
||||
# Les infos sur les étudiants
|
||||
self.etudiants = pe_etudiant.EtudiantsJuryPE(self.diplome)
|
||||
"""Les informations sur les étudiants du jury PE"""
|
||||
self.etudiants.find_etudiants()
|
||||
self.diplomes_ids = self.etudiants.diplomes_ids
|
||||
|
||||
self.rcss_jury = pe_rcss_jury.RCSsJuryPE(self.diplome, self.etudiants)
|
||||
"""Les informations sur les regroupements de semestres"""
|
||||
|
||||
self.zipdata = io.BytesIO()
|
||||
with ZipFile(self.zipdata, "w") as zipfile:
|
||||
if not self.diplomes_ids:
|
||||
pe_affichage.pe_print("*** Aucun étudiant diplômé", info=True)
|
||||
else:
|
||||
try:
|
||||
self._gen_xls_diplomes(zipfile)
|
||||
self._gen_xls_ressembuttags(zipfile)
|
||||
self._gen_trajectoires()
|
||||
self._gen_semXs()
|
||||
self._gen_xls_sxtags(zipfile)
|
||||
self._gen_rcsemxs()
|
||||
self._gen_xls_rcstags(zipfile)
|
||||
self._gen_xls_interclasstags(zipfile)
|
||||
self._gen_xls_synthese_jury_par_tag(zipfile)
|
||||
self._gen_html_synthese_par_etudiant(zipfile)
|
||||
except Exception as e:
|
||||
if pe_affichage.PE_DEBUG == True:
|
||||
raise e
|
||||
else:
|
||||
pe_affichage.pe_print(str(e))
|
||||
# raise e
|
||||
# et le log
|
||||
self._add_log_to_zip(zipfile)
|
||||
|
||||
# Fin !!!! Tada :)
|
||||
|
||||
def _gen_xls_diplomes(self, zipfile: ZipFile):
|
||||
"Intègre le bilan des semestres taggués au zip"
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
if self.diplomes_ids:
|
||||
onglet = "diplômés"
|
||||
df_diplome = self.etudiants.df_administratif(self.diplomes_ids)
|
||||
df_diplome.to_excel(writer, onglet, index=True, header=True)
|
||||
if self.etudiants.abandons_ids:
|
||||
onglet = "redoublants-réorientés"
|
||||
df_abandon = self.etudiants.df_administratif(
|
||||
self.etudiants.abandons_ids
|
||||
)
|
||||
df_abandon.to_excel(writer, onglet, index=True, header=True)
|
||||
output.seek(0)
|
||||
|
||||
self.add_file_to_zip(
|
||||
zipfile,
|
||||
f"etudiants_{self.diplome}.xlsx",
|
||||
output.read(),
|
||||
path="details",
|
||||
)
|
||||
|
||||
def _gen_xls_ressembuttags(self, zipfile: ZipFile):
|
||||
"""Calcule les moyennes par tag des résultats des Semestres BUT"""
|
||||
pe_affichage.pe_print(
|
||||
f"""*************************************************************************"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
f"""*** Génère les ResSemBUTTag (ResSemestreBUT taggués)""", info=True
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
f"""*************************************************************************"""
|
||||
)
|
||||
|
||||
# Tous les formsestres des étudiants
|
||||
formsemestres = get_formsemestres_etudiants(self.etudiants)
|
||||
pe_affichage.pe_print(
|
||||
f"1) Génère les {len(formsemestres)} ResSemBUTTag", info=True
|
||||
)
|
||||
|
||||
self.ressembuttags = {}
|
||||
for frmsem_id, formsemestre in formsemestres.items():
|
||||
# Crée le semestre_tag et exécute les calculs de moyennes
|
||||
ressembuttag = pe_ressemtag.ResSemBUTTag(formsemestre, options=self.options)
|
||||
self.ressembuttags[frmsem_id] = ressembuttag
|
||||
# Ajoute les étudiants découverts dans les ressembuttags aux données des étudiants
|
||||
# nbre_etudiants_ajoutes = self.etudiants.add_etudiants(
|
||||
# ressembuttag.etudids_sorted
|
||||
# )
|
||||
# if nbre_etudiants_ajoutes:
|
||||
# pe_affichage.pe_print(
|
||||
# f"--> Ajout de {nbre_etudiants_ajoutes} étudiants aux données du jury"
|
||||
# )
|
||||
|
||||
# Intègre le bilan des semestres taggués au zip final
|
||||
pe_affichage.pe_print(f"2) Bilan", info=True)
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
onglets = []
|
||||
for res_sem_tag in self.ressembuttags.values():
|
||||
if res_sem_tag.is_significatif():
|
||||
onglet = res_sem_tag.get_repr(verbose=True)
|
||||
onglet = onglet.replace("Semestre ", "S")
|
||||
onglets += ["📊" + onglet]
|
||||
df = res_sem_tag.to_df(options=self.options)
|
||||
# Conversion colonnes en multiindex
|
||||
df = convert_colonnes_to_multiindex(df)
|
||||
# écriture dans l'onglet
|
||||
df.to_excel(writer, onglet, index=True, header=True)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Export excel de {', '.join(onglets)}", info=True
|
||||
)
|
||||
output.seek(0)
|
||||
|
||||
self.add_file_to_zip(
|
||||
zipfile,
|
||||
f"ResSemBUTTags_{self.diplome}.xlsx",
|
||||
output.read(),
|
||||
path="details",
|
||||
)
|
||||
|
||||
def _gen_trajectoires(self):
|
||||
"""Génère l'ensemble des trajectoires (RCS), qui traduisent les différents
|
||||
chemins au sein des (form)semestres pour atteindre la cible d'un
|
||||
RCS (par ex: 'S2' ou '3S').
|
||||
"""
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"*** Génère les trajectoires (≠tes combinaisons de semestres) des étudiants",
|
||||
info=True,
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
|
||||
self.rcss_jury.cree_trajectoires()
|
||||
pe_affichage.aff_trajectoires_suivies_par_etudiants(self.etudiants)
|
||||
|
||||
def _gen_semXs(self):
|
||||
"""Génère les SemXs (trajectoires/combinaisons de semestre de même rang x)
|
||||
qui traduisent les différents chemins des étudiants pour valider un semestre Sx.
|
||||
"""
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"*** Génère les SemXs (RCS de même Sx donnant lieu à validation du semestre)",
|
||||
info=True,
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
|
||||
# Génère les regroupements de semestres de type Sx
|
||||
|
||||
self.rcss_jury.cree_semxs()
|
||||
pe_affichage.aff_semXs_suivis_par_etudiants(self.etudiants)
|
||||
|
||||
def _gen_xls_sxtags(self, zipfile: ZipFile):
|
||||
"""Génère les semestres taggués en s'appuyant sur les RCF de type Sx (pour
|
||||
identifier les redoublements impactant les semestres taggués).
|
||||
"""
|
||||
# Génère les moyennes des RCS de type Sx
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"*** Calcule les moyennes des SxTag (moyennes d'un RCS de type Sx)",
|
||||
info=True,
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"***************************************************************************"
|
||||
)
|
||||
|
||||
# Les SxTag (moyenne de Sx par UE)
|
||||
pe_affichage.pe_print("1) Calcul des moyennes", info=True)
|
||||
self.sxtags = {}
|
||||
for rcf_id, rcf in self.rcss_jury.semXs.items():
|
||||
# SxTag traduisant le RCF
|
||||
sxtag_id = rcf_id
|
||||
self.sxtags[sxtag_id] = pe_sxtag.SxTag(sxtag_id, rcf, self.ressembuttags)
|
||||
|
||||
# Intègre le bilan des semestres taggués au zip final
|
||||
pe_affichage.pe_print("2) Bilan", info=True)
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
onglets = []
|
||||
for sxtag in self.sxtags.values():
|
||||
if sxtag.is_significatif():
|
||||
onglet = sxtag.get_repr(verbose=False)
|
||||
onglets += ["📊" + onglet]
|
||||
df = sxtag.to_df(options=self.options)
|
||||
# Conversion colonnes en multiindex
|
||||
df = convert_colonnes_to_multiindex(df)
|
||||
|
||||
# écriture dans l'onglet
|
||||
df.to_excel(writer, onglet, index=True, header=True)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Export excel de {', '.join(onglets)}", info=True
|
||||
)
|
||||
|
||||
output.seek(0)
|
||||
if onglets:
|
||||
self.add_file_to_zip(
|
||||
zipfile,
|
||||
f"semestres_taggues_{self.diplome}.xlsx",
|
||||
output.read(),
|
||||
path="details",
|
||||
)
|
||||
|
||||
def _gen_rcsemxs(self):
|
||||
"""Génère les regroupements cohérents de RCFs qu'ont suivi chaque étudiant"""
|
||||
|
||||
pe_affichage.pe_print(
|
||||
"""******************************************************************************"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""*** Génère les RCSemX (regroupements cohérents de données extraites des SemX)\n"""
|
||||
"""*** amenant du S1 à un semestre final""",
|
||||
info=True,
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""******************************************************************************"""
|
||||
)
|
||||
self.rcss_jury.cree_rcsemxs(options=self.options)
|
||||
if "moyennes_ues_rcues" in self.options and self.options["moyennes_ues_rcues"]:
|
||||
pe_affichage.aff_rcsemxs_suivis_par_etudiants(self.etudiants)
|
||||
|
||||
def _gen_xls_rcstags(self, zipfile: ZipFile):
|
||||
"""Génère les RCS taggués traduisant les moyennes (orientées compétences)
|
||||
de regroupements de semestre de type Sx, xA ou xS.
|
||||
|
||||
Stocke le résultat dans self.rccs_tag, un dictionnaire de
|
||||
la forme ``{nom_aggregat: {fid_terminal: RCSTag(fid_terminal)} }``
|
||||
|
||||
Pour rappel : Chaque RCS est identifié par un nom d'aggrégat et par un formsemestre terminal.
|
||||
|
||||
Par exemple :
|
||||
|
||||
* combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les
|
||||
étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison.
|
||||
|
||||
Args:
|
||||
etudiants: Les données des étudiants
|
||||
semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés)
|
||||
"""
|
||||
|
||||
# Génère les moyennes des RCS de type Sx
|
||||
pe_affichage.pe_print(
|
||||
"""****************************************************"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""*** Génère les moyennes associées aux RCSemX""", info=True
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""****************************************************"""
|
||||
)
|
||||
|
||||
pe_affichage.pe_print("1) Calcul des moyennes des RCSTag", info=True)
|
||||
if not self.rcss_jury.rcsemxs:
|
||||
if (
|
||||
"moyennes_ues_rcues" in self.options
|
||||
and not self.options["moyennes_ues_rcues"]
|
||||
):
|
||||
pe_affichage.pe_print(" -> Pas de RCSemX à calculer (cf. options)")
|
||||
else:
|
||||
pe_affichage.pe_print(
|
||||
" -> Pas de RCSemX à calculer (alors qu'aucune option ne les limite) => problème"
|
||||
)
|
||||
self.rcsstags = {}
|
||||
return
|
||||
|
||||
# Calcul des RCSTags sur la base des RCSemX
|
||||
self.rcsstags = {}
|
||||
for rcs_id, rcsemx in self.rcss_jury.rcsemxs.items():
|
||||
self.rcsstags[rcs_id] = pe_rcstag.RCSemXTag(
|
||||
rcsemx, self.sxtags, self.rcss_jury.semXs_suivis
|
||||
)
|
||||
|
||||
# Intègre le bilan des trajectoires tagguées au zip final
|
||||
pe_affichage.pe_print("2) Bilan", info=True)
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
onglets = []
|
||||
for rcs_tag in self.rcsstags.values():
|
||||
if rcs_tag.is_significatif():
|
||||
onglet = rcs_tag.get_repr(verbose=False)
|
||||
onglets += ["📊" + onglet]
|
||||
|
||||
df = rcs_tag.to_df(options=self.options)
|
||||
# Conversion colonnes en multiindex
|
||||
df = convert_colonnes_to_multiindex(df)
|
||||
onglets += ["📊" + onglet]
|
||||
# écriture dans l'onglet
|
||||
df.to_excel(writer, onglet, index=True, header=True)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Export excel de {', '.join(onglets)}", info=True
|
||||
)
|
||||
output.seek(0)
|
||||
|
||||
if onglets:
|
||||
self.add_file_to_zip(
|
||||
zipfile,
|
||||
f"RCRCFs_{self.diplome}.xlsx",
|
||||
output.read(),
|
||||
path="details",
|
||||
)
|
||||
|
||||
def _gen_xls_interclasstags(self, zipfile: ZipFile):
|
||||
"""Génère les interclassements sur la promo de diplômés
|
||||
par (nom d') aggrégat
|
||||
en distinguant les interclassements par accronymes d'UEs (sur les SxTag)
|
||||
et ceux par compétences (sur les RCSTag).
|
||||
"""
|
||||
pe_affichage.pe_print(
|
||||
"""******************************************************************"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""*** Génère les interclassements sur chaque type de RCS/agrégat"""
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"""******************************************************************"""
|
||||
)
|
||||
|
||||
if (
|
||||
"moyennes_ues_rcues" not in self.options
|
||||
or self.options["moyennes_ues_rcues"]
|
||||
):
|
||||
self.interclasstags = {
|
||||
pe_moytag.CODE_MOY_UE: {},
|
||||
pe_moytag.CODE_MOY_COMPETENCES: {},
|
||||
}
|
||||
else:
|
||||
self.interclasstags = {pe_moytag.CODE_MOY_UE: {}}
|
||||
|
||||
etudiants_diplomes = self.etudiants.etudiants_diplomes
|
||||
|
||||
# Les interclassements par UE (toujours présents par défaut)
|
||||
for Sx in pe_rcs.TOUS_LES_SEMESTRES:
|
||||
interclass = pe_interclasstag.InterClassTag(
|
||||
Sx,
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
etudiants_diplomes,
|
||||
self.rcss_jury.semXs,
|
||||
self.sxtags,
|
||||
self.rcss_jury.semXs_suivis,
|
||||
)
|
||||
self.interclasstags[pe_moytag.CODE_MOY_UE][Sx] = interclass
|
||||
|
||||
# Les interclassements par compétences
|
||||
if (
|
||||
"moyennes_ues_rcues" not in self.options
|
||||
or self.options["moyennes_ues_rcues"]
|
||||
):
|
||||
for nom_rcs in pe_rcs.TOUS_LES_RCS:
|
||||
interclass = pe_interclasstag.InterClassTag(
|
||||
nom_rcs,
|
||||
pe_moytag.CODE_MOY_COMPETENCES,
|
||||
etudiants_diplomes,
|
||||
self.rcss_jury.rcsemxs,
|
||||
self.rcsstags,
|
||||
self.rcss_jury.rcsemxs_suivis,
|
||||
)
|
||||
self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][
|
||||
nom_rcs
|
||||
] = interclass
|
||||
|
||||
# Intègre le bilan des aggrégats (interclassé par promo) au zip final
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
onglets = []
|
||||
for (
|
||||
type_interclass
|
||||
) in self.interclasstags: # Pour les types d'interclassements prévus
|
||||
interclasstag = self.interclasstags[type_interclass]
|
||||
for nom_rcs, interclass in interclasstag.items():
|
||||
if interclass.is_significatif():
|
||||
onglet = interclass.get_repr()
|
||||
onglets += ["📊" + onglet]
|
||||
df = interclass.to_df(cohorte="Promo", options=self.options)
|
||||
# Conversion colonnes en multiindex
|
||||
df = convert_colonnes_to_multiindex(df)
|
||||
onglets += [onglet]
|
||||
# écriture dans l'onglet
|
||||
df.to_excel(writer, onglet, index=True, header=True)
|
||||
pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
if onglets:
|
||||
self.add_file_to_zip(
|
||||
zipfile,
|
||||
f"InterClassTags_{self.diplome}.xlsx",
|
||||
output.read(),
|
||||
path="details",
|
||||
)
|
||||
|
||||
def _gen_xls_synthese_jury_par_tag(self, zipfile: ZipFile):
|
||||
"""Synthèse des éléments du jury PE tag par tag"""
|
||||
pe_affichage.pe_print(
|
||||
"**************************************************************************************"
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"*** Synthèse finale des moyennes par tag et par type de moyennes (UEs ou Compétences)",
|
||||
info=True,
|
||||
)
|
||||
pe_affichage.pe_print(
|
||||
"**************************************************************************************"
|
||||
)
|
||||
|
||||
self.synthese = {}
|
||||
pe_affichage.pe_print(" -> Synthèse des données administratives", info=True)
|
||||
self.synthese["administratif"] = self.etudiants.df_administratif(
|
||||
self.diplomes_ids
|
||||
)
|
||||
|
||||
tags = self._do_tags_list(self.interclasstags)
|
||||
for tag in tags:
|
||||
for type_moy in self.interclasstags:
|
||||
self.synthese[(tag, type_moy)] = self.df_tag_type(tag, type_moy)
|
||||
|
||||
# Export des données => mode 1 seule feuille -> supprimé
|
||||
pe_affichage.pe_print("*** Export du jury de synthese par tags", info=True)
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
|
||||
output, engine="openpyxl"
|
||||
) as writer:
|
||||
onglets = []
|
||||
for onglet, df in self.synthese.items():
|
||||
# Conversion colonnes en multiindex
|
||||
df_final = df.copy()
|
||||
if (
|
||||
"publipostage" not in self.options
|
||||
or not self.options["publipostage"]
|
||||
):
|
||||
df_final = convert_colonnes_to_multiindex(df_final)
|
||||
# Nom de l'onglet
|
||||
if isinstance(onglet, tuple):
|
||||
(repr, type_moy) = onglet
|
||||
nom_onglet = onglet[0][: 31 - 7]
|
||||
if type_moy == pe_moytag.CODE_MOY_COMPETENCES:
|
||||
nom_onglet = nom_onglet + " (Comp)"
|
||||
else:
|
||||
nom_onglet = nom_onglet + " (UEs)"
|
||||
else:
|
||||
nom_onglet = onglet
|
||||
onglets += [nom_onglet]
|
||||
# écriture dans l'onglet:
|
||||
df_final = df_final.replace("nan", "")
|
||||
df_final.to_excel(writer, nom_onglet, index=True, header=True)
|
||||
pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True)
|
||||
output.seek(0)
|
||||
|
||||
if onglets:
|
||||
self.add_file_to_zip(
|
||||
zipfile, f"synthese_jury_{self.diplome}.xlsx", output.read()
|
||||
)
|
||||
|
||||
def _gen_html_synthese_par_etudiant(self, zipfile: ZipFile):
|
||||
"""Synthèse des éléments du jury PE, étudiant par étudiant"""
|
||||
# Synthèse des éléments du jury PE
|
||||
pe_affichage.pe_print("**************************************************")
|
||||
pe_affichage.pe_print("*** Synthèse finale étudiant par étudiant", info=True)
|
||||
pe_affichage.pe_print("**************************************************")
|
||||
|
||||
if (
|
||||
"moyennes_ues_rcues" not in self.options
|
||||
or self.options["moyennes_ues_rcues"]
|
||||
):
|
||||
etudids = list(self.diplomes_ids)
|
||||
for etudid in etudids:
|
||||
nom, prenom, html = self.synthetise_jury_etudiant(etudid)
|
||||
self.add_file_to_zip(
|
||||
zipfile, f"{nom}_{prenom}.html", html, path="etudiants"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(" > Pas de synthèse étudiant/étudiant possible/prévu")
|
||||
|
||||
def _add_log_to_zip(self, zipfile):
|
||||
"""Add a text file with the log messages"""
|
||||
log_data = pe_affichage.pe_get_log()
|
||||
self.add_file_to_zip(zipfile, "pe_log.txt", log_data)
|
||||
|
||||
def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""):
|
||||
"""Add a file to given zip
|
||||
All files under NOM_EXPORT_ZIP/
|
||||
path may specify a subdirectory
|
||||
|
||||
Args:
|
||||
zipfile: ZipFile
|
||||
filename: Le nom du fichier à intégrer au zip
|
||||
data: Les données du fichier
|
||||
path: Un dossier dans l'arborescence du zip
|
||||
"""
|
||||
path_in_zip = os.path.join(path, filename) # self.nom_export_zip,
|
||||
zipfile.writestr(path_in_zip, data)
|
||||
|
||||
def get_zipped_data(self) -> io.BytesIO | None:
|
||||
"""returns file-like data with a zip of all generated (CSV) files.
|
||||
Warning: reset stream to the begining.
|
||||
"""
|
||||
self.zipdata.seek(0)
|
||||
return self.zipdata
|
||||
|
||||
def _do_tags_list(self, interclassements: dict[str, dict]):
|
||||
"""La liste des tags extraites des interclassements"""
|
||||
tags = []
|
||||
# Pour chaque type d'interclassements
|
||||
for type in interclassements:
|
||||
interclassement = interclassements[type]
|
||||
for aggregat in interclassement:
|
||||
interclass = interclassement[aggregat]
|
||||
if interclass.tags_sorted:
|
||||
tags.extend(interclass.tags_sorted)
|
||||
tags = sorted(set(tags))
|
||||
return tags
|
||||
|
||||
# **************************************************************************************************************** #
|
||||
# Méthodes pour la synthèse du juryPE
|
||||
# *****************************************************************************************************************
|
||||
|
||||
def df_tag_type(self, tag, type_moy):
|
||||
"""Génère le DataFrame synthétisant les moyennes/classements (groupe +
|
||||
interclassement promo) pour tous les aggrégats prévus, en fonction
|
||||
du type (UEs ou Compétences) de données souhaitées,
|
||||
tels que fourni dans l'excel final.
|
||||
|
||||
Si type=UEs => tous les sxtag du tag
|
||||
Si type=Compétences => tous les rcstag du tag
|
||||
|
||||
Args:
|
||||
tag: Un des tags (a minima `but`)
|
||||
type_moy: Un type de moyenne
|
||||
|
||||
Returns:
|
||||
"""
|
||||
|
||||
# Les données des étudiants
|
||||
etuds = [etud for etudid, etud in self.etudiants.etudiants_diplomes.items()]
|
||||
df = pe_tabletags.df_administratif(etuds, aggregat="Administratif", cohorte="")
|
||||
|
||||
if type_moy == pe_moytag.CODE_MOY_UE:
|
||||
aggregats = pe_rcs.TOUS_LES_SEMESTRES
|
||||
else:
|
||||
aggregats = pe_rcs.TOUS_LES_RCS
|
||||
|
||||
aff_aggregat = []
|
||||
for aggregat in aggregats:
|
||||
# Descr de l'aggrégat
|
||||
descr = pe_rcs.TYPES_RCS[aggregat]["descr"]
|
||||
|
||||
# L'interclassement associé
|
||||
interclass = self.interclasstags[type_moy][aggregat]
|
||||
|
||||
if interclass.is_significatif():
|
||||
# Le dataframe du classement sur le groupe
|
||||
df_groupe = interclass.compute_df_synthese_moyennes_tag(
|
||||
tag, aggregat=aggregat, type_colonnes=False, options=self.options
|
||||
)
|
||||
if not df_groupe.empty:
|
||||
aff_aggregat += [aggregat]
|
||||
df = df.join(df_groupe)
|
||||
|
||||
# Le dataframe du classement sur la promo
|
||||
df_promo = interclass.to_df(
|
||||
administratif=False,
|
||||
aggregat=aggregat,
|
||||
tags_cibles=[tag],
|
||||
cohorte="Promo",
|
||||
options=self.options,
|
||||
)
|
||||
|
||||
if not df_promo.empty:
|
||||
aff_aggregat += [aggregat]
|
||||
df = df.join(df_promo)
|
||||
|
||||
if aff_aggregat:
|
||||
pe_affichage.pe_print(
|
||||
f" -> Synthèse de 👜{tag} par {type_moy} avec {', '.join(aff_aggregat)}"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(f" -> Synthèse du tag {tag} par {type_moy} : <vide>")
|
||||
|
||||
return df
|
||||
# Fin de l'aggrégat
|
||||
|
||||
def synthetise_jury_etudiant(self, etudid) -> (str, str, str):
|
||||
"""Synthétise les résultats d'un étudiant dans un
|
||||
fichier html à son nom en s'appuyant sur la synthese final
|
||||
|
||||
Returns:
|
||||
Un tuple nom, prenom, html
|
||||
"""
|
||||
etudiant = self.etudiants.identites[etudid]
|
||||
nom = etudiant.nom
|
||||
prenom = etudiant.prenom # initial du prénom
|
||||
parcours = self.etudiants.cursus[etudid]["parcours"]
|
||||
if not parcours:
|
||||
parcours = "<parcours indéterminé>"
|
||||
|
||||
# Accès au template
|
||||
environnement = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader("app/templates/")
|
||||
)
|
||||
template = environnement.get_template("pe/pe_view_resultats_etudiant.j2")
|
||||
|
||||
# Colonnes des tableaux htmls => competences
|
||||
competences = []
|
||||
for aggregat in pe_rcs.TOUS_LES_RCS:
|
||||
# L'interclassement associé
|
||||
interclass = self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][aggregat]
|
||||
competences.extend(interclass.champs_sorted)
|
||||
competences = sorted(set(competences))
|
||||
colonnes_html = competences
|
||||
|
||||
tags = self._do_tags_list(self.interclasstags)
|
||||
|
||||
# Les données par UE
|
||||
moyennes = {}
|
||||
for tag in tags:
|
||||
moyennes[tag] = {}
|
||||
# Les données de synthèse
|
||||
df = self.synthese[(tag, pe_moytag.CODE_MOY_COMPETENCES)]
|
||||
for aggregat in pe_rcs.TOUS_LES_RCS:
|
||||
# moyennes[tag][aggregat] = {}
|
||||
descr = pe_rcs.get_descr_rcs(aggregat)
|
||||
|
||||
moy = {}
|
||||
est_significatif = False
|
||||
for comp in competences + ["Général"]:
|
||||
moy[comp] = {
|
||||
"note": "",
|
||||
"rang_groupe": "",
|
||||
"rang_promo": "",
|
||||
}
|
||||
colonne = pe_moytag.get_colonne_df(
|
||||
aggregat, tag, comp, "Groupe", "note"
|
||||
)
|
||||
if colonne in df.columns:
|
||||
valeur = df.loc[etudid, colonne]
|
||||
if not np.isnan(valeur):
|
||||
moy[comp]["note"] = round(valeur, 2)
|
||||
est_significatif = True
|
||||
# else:
|
||||
# print(f"{colonne} manquante")
|
||||
colonne = pe_moytag.get_colonne_df(
|
||||
aggregat, tag, comp, "Groupe", "rang"
|
||||
)
|
||||
if colonne in df.columns:
|
||||
valeur = df.loc[etudid, colonne]
|
||||
if valeur and str(valeur) != "nan":
|
||||
moy[comp]["rang_groupe"] = valeur
|
||||
colonne = pe_moytag.get_colonne_df(
|
||||
aggregat, tag, comp, "Promo", "rang"
|
||||
)
|
||||
if colonne in df.columns:
|
||||
valeur = df.loc[etudid, colonne]
|
||||
if valeur and str(valeur) != "nan":
|
||||
moy[comp]["rang_promo"] = valeur
|
||||
|
||||
if est_significatif:
|
||||
moyennes[tag][descr] = moy
|
||||
|
||||
html = template.render(
|
||||
nom=nom,
|
||||
prenom=prenom,
|
||||
parcours=parcours,
|
||||
colonnes_html=colonnes_html,
|
||||
tags=tags,
|
||||
moyennes=moyennes,
|
||||
)
|
||||
|
||||
return (nom, prenom, html)
|
||||
|
||||
|
||||
def get_formsemestres_etudiants(etudiants: pe_etudiant.EtudiantsJuryPE) -> dict:
|
||||
"""Ayant connaissance des étudiants dont il faut calculer les moyennes pour
|
||||
le jury PE (attribut `self.etudiant_ids) et de leurs trajectoires (semestres
|
||||
parcourus), renvoie un dictionnaire ``{fid: FormSemestre(fid)}``
|
||||
contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer
|
||||
la moyenne.
|
||||
|
||||
Args:
|
||||
etudiants: Les étudiants du jury PE
|
||||
|
||||
Returns:
|
||||
Un dictionnaire de la forme `{fid: FormSemestre(fid)}`
|
||||
"""
|
||||
semestres = {}
|
||||
for etudid in etudiants.etudiants_ids:
|
||||
for cle in etudiants.cursus[etudid]:
|
||||
if cle.startswith("S"):
|
||||
semestres = semestres | etudiants.cursus[etudid][cle]
|
||||
return semestres
|
||||
|
||||
|
||||
def convert_colonnes_to_multiindex(df):
|
||||
"""Convertit les colonnes d'un df pour obtenir des colonnes
|
||||
multiindex"""
|
||||
df_final = df.copy()
|
||||
colonnes = list(df.columns)
|
||||
colonnes = [tuple(col.split("|")) for col in colonnes]
|
||||
# modifie le nom du semestre par sa descr
|
||||
colonnes_verbose = []
|
||||
|
||||
for col in colonnes:
|
||||
if col[0] in pe_rcs.TYPES_RCS:
|
||||
descr = pe_rcs.get_descr_rcs(col[0])
|
||||
col_verbose = [descr] + list(col[1:])
|
||||
col_verbose = tuple(col_verbose)
|
||||
else:
|
||||
col_verbose = col
|
||||
colonnes_verbose.append(col_verbose)
|
||||
|
||||
df_final.columns = pd.MultiIndex.from_tuples(colonnes_verbose)
|
||||
return df_final
|
1271
app/pe/pe_jurype.py
1271
app/pe/pe_jurype.py
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue