forked from ScoDoc/ScoDoc
Compare commits
515 Commits
Author | SHA1 | Date |
---|---|---|
iziram | 98e709bfd0 | |
Emmanuel Viennet | cdcb4a2468 | |
Emmanuel Viennet | 03db1e183e | |
Emmanuel Viennet | 6194bdc5ed | |
Emmanuel Viennet | bfa61cf035 | |
Emmanuel Viennet | a267c69501 | |
Emmanuel Viennet | e16e4a0ff3 | |
Emmanuel Viennet | fec6ccfb28 | |
Emmanuel Viennet | d79cd95dff | |
Emmanuel Viennet | c732536922 | |
Emmanuel Viennet | 92d5bd9454 | |
Emmanuel Viennet | 7eef38aefe | |
Emmanuel Viennet | 94ec3266ed | |
Emmanuel Viennet | 5ed92f9080 | |
Emmanuel Viennet | 212616655b | |
Emmanuel Viennet | 1f818da064 | |
Emmanuel Viennet | c6f81d1301 | |
Emmanuel Viennet | 23a118a8cc | |
Emmanuel Viennet | ca5abc9c22 | |
Emmanuel Viennet | 0e2a2d4b3b | |
Emmanuel Viennet | 963a326426 | |
Emmanuel Viennet | 0a093f420f | |
Emmanuel Viennet | 9d76ef4d5d | |
Emmanuel Viennet | 59cfb94b9d | |
Emmanuel Viennet | 38262c066a | |
Emmanuel Viennet | fabe9c90cf | |
Emmanuel Viennet | e2ac77332c | |
Jean-Marie Place | dbcd65e2d4 | |
Emmanuel Viennet | 7996ec2ecd | |
Emmanuel Viennet | 3f499f7631 | |
Emmanuel Viennet | 40b6722743 | |
Emmanuel Viennet | f214aa8507 | |
Emmanuel Viennet | cd56337958 | |
Emmanuel Viennet | 257ad34724 | |
Emmanuel Viennet | 99812ca25d | |
Emmanuel Viennet | 60711d674b | |
Emmanuel Viennet | d5dfa37b91 | |
Emmanuel Viennet | ee2c9ccb84 | |
Emmanuel Viennet | 407129da0f | |
Emmanuel Viennet | 422a200e88 | |
Emmanuel Viennet | d05ec0e4f1 | |
Emmanuel Viennet | 98accd7a6a | |
Emmanuel Viennet | 0011427302 | |
Emmanuel Viennet | e02db2a751 | |
Emmanuel Viennet | 6ec4011a3d | |
Emmanuel Viennet | 9c2c8d9047 | |
Emmanuel Viennet | be76fc8f42 | |
Emmanuel Viennet | e159ce883c | |
Jean-Marie Place | b1cb4ddea3 | |
Emmanuel Viennet | 8fce660173 | |
Emmanuel Viennet | 3c42bae235 | |
Emmanuel Viennet | b1f203bf25 | |
Emmanuel Viennet | 8dbe5f7926 | |
Emmanuel Viennet | 0196d96543 | |
Emmanuel Viennet | 40017ad69b | |
Emmanuel Viennet | 31581419a7 | |
Emmanuel Viennet | 25bd2b6e45 | |
Emmanuel Viennet | 2a8ee95df7 | |
Emmanuel Viennet | b98d9c2036 | |
Emmanuel Viennet | 76236f1125 | |
Emmanuel Viennet | 3458e5f611 | |
Emmanuel Viennet | 12d1e7fe99 | |
Emmanuel Viennet | a407856cbb | |
Emmanuel Viennet | a7437bfdc5 | |
Emmanuel Viennet | d3ba09e6da | |
Emmanuel Viennet | 6a0713b432 | |
Emmanuel Viennet | 935ae99e03 | |
Emmanuel Viennet | 363de7be76 | |
Emmanuel Viennet | 2f84d9968c | |
Emmanuel Viennet | 94d49ac870 | |
Emmanuel Viennet | 175f65cd1f | |
Emmanuel Viennet | 8ddb3eb427 | |
Emmanuel Viennet | 1a6aa269ee | |
Emmanuel Viennet | 9264ac7b31 | |
Emmanuel Viennet | fdc819e904 | |
Emmanuel Viennet | 8dce157d06 | |
Emmanuel Viennet | 50191e6f77 | |
Emmanuel Viennet | b359aa5c93 | |
Emmanuel Viennet | 88e092f76b | |
Emmanuel Viennet | de11836479 | |
Emmanuel Viennet | c7731f0455 | |
Emmanuel Viennet | 88320ce95f | |
Emmanuel Viennet | 784b3eea8c | |
Emmanuel Viennet | 6dc19b6f80 | |
Emmanuel Viennet | 6c7f0ef72f | |
Emmanuel Viennet | c8a70670be | |
Emmanuel Viennet | 78fa0a88cf | |
Emmanuel Viennet | 07f478d6ea | |
Emmanuel Viennet | 8e7509b035 | |
Emmanuel Viennet | 5d77d415a2 | |
Emmanuel Viennet | 6809f24cee | |
Emmanuel Viennet | e62038ea59 | |
Emmanuel Viennet | fd34984b29 | |
Emmanuel Viennet | 3ac806220c | |
Emmanuel Viennet | f0c0490816 | |
Emmanuel Viennet | bbcd6d7b33 | |
Emmanuel Viennet | c98df4529e | |
iziram | 4f71575154 | |
iziram | b73a02ac67 | |
Emmanuel Viennet | 4c648212dd | |
Emmanuel Viennet | 157adf76e4 | |
Emmanuel Viennet | 29d8c7b0e6 | |
Emmanuel Viennet | 300dd4ac22 | |
Emmanuel Viennet | baaf8b0244 | |
Emmanuel Viennet | f60eba1b9c | |
Emmanuel Viennet | 48a3950cfa | |
Emmanuel Viennet | 32efafd61d | |
Emmanuel Viennet | 1a80202468 | |
Emmanuel Viennet | 77723982f5 | |
Emmanuel Viennet | fd5fefec71 | |
Emmanuel Viennet | 693f6c9cbd | |
Emmanuel Viennet | a9b809655b | |
Emmanuel Viennet | 1e0645fd14 | |
Emmanuel Viennet | 83e271ad8c | |
Emmanuel Viennet | a84a5da836 | |
Emmanuel Viennet | 6235f2346e | |
Emmanuel Viennet | 831b6a1039 | |
Emmanuel Viennet | 8d91505b8b | |
Emmanuel Viennet | cc0eca20fa | |
Emmanuel Viennet | 0d9b810dd9 | |
Emmanuel Viennet | 58fb6ecea0 | |
Emmanuel Viennet | 77b877c063 | |
Emmanuel Viennet | 67e7e6cb1d | |
Jean-Marie Place | a3260f05b0 | |
Jean-Marie Place | 64f6b01140 | |
Emmanuel Viennet | 0b118a6947 | |
Emmanuel Viennet | 1b022231f8 | |
Emmanuel Viennet | c78745cd3d | |
Emmanuel Viennet | 71114c391d | |
Emmanuel Viennet | 39f7e1b63f | |
Emmanuel Viennet | e68c957c62 | |
Emmanuel Viennet | aa631a8a27 | |
Emmanuel Viennet | 6dc770f79b | |
Jean-Marie Place | 72e96abfd0 | |
Emmanuel Viennet | 0bc546853d | |
Emmanuel Viennet | 120901d9dc | |
Emmanuel Viennet | 6efd2d2e6e | |
Emmanuel Viennet | 8e77689ff0 | |
Emmanuel Viennet | c649c05628 | |
Sébastien Lehmann | 5861a2d802 | |
Emmanuel Viennet | 86cf10d26d | |
Emmanuel Viennet | 87776b412c | |
Emmanuel Viennet | 3dcf8495b0 | |
Emmanuel Viennet | 8d7958c80d | |
Emmanuel Viennet | 0e5b4f9cb7 | |
Emmanuel Viennet | 42a63298b4 | |
Emmanuel Viennet | 574f7fc376 | |
Emmanuel Viennet | 5f9c525d39 | |
Emmanuel Viennet | 527a73b65a | |
Emmanuel Viennet | 28baca0696 | |
Emmanuel Viennet | c0a4f40803 | |
Emmanuel Viennet | 95b7f813ff | |
iziram | 3c227562ff | |
iziram | b9554cd6b5 | |
iziram | 6468bb30e4 | |
iziram | 104a7058a1 | |
iziram | 345a842f59 | |
iziram | 5aeffcbf1d | |
Emmanuel Viennet | 84ac334b13 | |
Emmanuel Viennet | 84a333c6d9 | |
Emmanuel Viennet | f78653c184 | |
Emmanuel Viennet | ca5ea5148d | |
Emmanuel Viennet | 7c25e14dda | |
Emmanuel Viennet | 06ad4f39a9 | |
Emmanuel Viennet | 41a4610b7a | |
Emmanuel Viennet | 12ce1d16f2 | |
Sébastien Lehmann | 462db3f9ef | |
Emmanuel Viennet | 107341efab | |
Emmanuel Viennet | 7739530383 | |
Emmanuel Viennet | 7018a41425 | |
Emmanuel Viennet | a992d80982 | |
Emmanuel Viennet | a8f97bedde | |
Emmanuel Viennet | 73a242663f | |
Emmanuel Viennet | eae49810dd | |
Emmanuel Viennet | 6bafcdbcac | |
Emmanuel Viennet | 323522a27c | |
Emmanuel Viennet | 9a7c98e906 | |
Emmanuel Viennet | c5e7012237 | |
Emmanuel Viennet | 83d0200e57 | |
Emmanuel Viennet | 18d5dc5fbd | |
Emmanuel Viennet | 3fb157e316 | |
Emmanuel Viennet | 613837ac37 | |
Emmanuel Viennet | bd1c2f1cb3 | |
Emmanuel Viennet | 8ecdfd4e62 | |
Emmanuel Viennet | db58c57a78 | |
Emmanuel Viennet | c49bc44700 | |
Emmanuel Viennet | ecb3748c85 | |
Emmanuel Viennet | 40919063f8 | |
Emmanuel Viennet | 6a985f8558 | |
Emmanuel Viennet | 91176d282c | |
Emmanuel Viennet | c45a229e95 | |
Emmanuel Viennet | b8f5cf712f | |
Emmanuel Viennet | 705fcc31cd | |
Emmanuel Viennet | b7b12d20ad | |
Emmanuel Viennet | 5144f9b8e1 | |
Emmanuel Viennet | 0bd873ba65 | |
Emmanuel Viennet | e84e55467a | |
Emmanuel Viennet | 87ff6b793c | |
Emmanuel Viennet | 95a3a74ce8 | |
Emmanuel Viennet | 48f54fa232 | |
Emmanuel Viennet | bf6d718e47 | |
Emmanuel Viennet | d77f745437 | |
iziram | 0f3e1ea95e | |
iziram | aa956f4530 | |
iziram | 21f57aab8f | |
iziram | 53c9658ce1 | |
iziram | e18990d804 | |
iziram | c11599b64f | |
iziram | 095eb6ce20 | |
iziram | 61d4186ad3 | |
iziram | 4d72fec42d | |
Emmanuel Viennet | a63e14ce06 | |
Emmanuel Viennet | ba909d72f0 | |
Emmanuel Viennet | 44a72c1ab9 | |
iziram | 2fd1b039f4 | |
Emmanuel Viennet | 0f8998c891 | |
Emmanuel Viennet | be367de2a1 | |
Emmanuel Viennet | e81ad610b6 | |
Emmanuel Viennet | 0f45101000 | |
Emmanuel Viennet | 1224b46846 | |
Emmanuel Viennet | 6b2ea5c5bc | |
iziram | a7b856b1ec | |
iziram | 547040bb93 | |
iziram | 8bc780f2cf | |
Emmanuel Viennet | 86f5751e79 | |
Emmanuel Viennet | b160f64e4f | |
Emmanuel Viennet | ee2ac9d986 | |
Emmanuel Viennet | fc78484186 | |
Emmanuel Viennet | 21b5474a6f | |
Emmanuel Viennet | 3c1acc9c00 | |
Emmanuel Viennet | 2548a97515 | |
Emmanuel Viennet | ce1cb7516b | |
iziram | cf3258f5f9 | |
Emmanuel Viennet | 3998b5a366 | |
Emmanuel Viennet | 728010bf69 | |
Emmanuel Viennet | e9f23d8b3e | |
Emmanuel Viennet | f6d442beb4 | |
Emmanuel Viennet | b19c94a1f4 | |
Emmanuel Viennet | 9fb70aef5d | |
Emmanuel Viennet | c8c05ecd77 | |
iziram | 02ec55ca18 | |
Emmanuel Viennet | b30d5eb996 | |
Emmanuel Viennet | e33bc1e303 | |
Emmanuel Viennet | d2362c1080 | |
Emmanuel Viennet | 6e4bf424e5 | |
Emmanuel Viennet | 2170b06e33 | |
Emmanuel Viennet | 696f4a5410 | |
Emmanuel Viennet | 9880645c01 | |
Emmanuel Viennet | 4d0ea06559 | |
Emmanuel Viennet | f46e3f6db5 | |
Emmanuel Viennet | 4d66fb13ee | |
Emmanuel Viennet | 138f9597f5 | |
Emmanuel Viennet | 91e8c9185b | |
Emmanuel Viennet | f3b2c6d4fe | |
Emmanuel Viennet | 7277c9f999 | |
Emmanuel Viennet | 9a19919bae | |
Emmanuel Viennet | d97c0c08aa | |
Emmanuel Viennet | 325978a175 | |
Emmanuel Viennet | 135ca9fc1c | |
Emmanuel Viennet | a4072efe4c | |
Emmanuel Viennet | 4430eb9a61 | |
Emmanuel Viennet | 073c3c7c44 | |
Emmanuel Viennet | 75b87b24de | |
Emmanuel Viennet | e0f6b022b1 | |
Emmanuel Viennet | 98c6761f6a | |
Emmanuel Viennet | 53514ef919 | |
Emmanuel Viennet | 294ce1d708 | |
Emmanuel Viennet | cf63e1c038 | |
Emmanuel Viennet | 584a7af2a1 | |
Emmanuel Viennet | 635320fd62 | |
Emmanuel Viennet | 6867974957 | |
Emmanuel Viennet | 6ad415dfca | |
Emmanuel Viennet | 2919ff517c | |
Emmanuel Viennet | 89948db135 | |
Emmanuel Viennet | bdf90dfd69 | |
Emmanuel Viennet | 0b9c9be222 | |
Emmanuel Viennet | b5cf210112 | |
Emmanuel Viennet | 6833a28274 | |
Emmanuel Viennet | 5753ac92f4 | |
Emmanuel Viennet | 16cc35f63c | |
Emmanuel Viennet | 5e0922a4bf | |
Emmanuel Viennet | 10148bc7c0 | |
Emmanuel Viennet | 556d8e7cbf | |
Emmanuel Viennet | c6b2af5635 | |
Emmanuel Viennet | cc0c544519 | |
Emmanuel Viennet | 71116e6b39 | |
Emmanuel Viennet | 3121a6d54c | |
pascal.bouron | 83afc1d6a0 | |
Emmanuel Viennet | 5fc08b9716 | |
Emmanuel Viennet | e7559b7a78 | |
Emmanuel Viennet | d8a98b6e5b | |
Emmanuel Viennet | 1287aecc4b | |
Sébastien Lehmann | 549323e781 | |
Emmanuel Viennet | 0ff5fa46d9 | |
Emmanuel Viennet | 8d124eca3e | |
Emmanuel Viennet | 6a7638d7ff | |
Emmanuel Viennet | 452bbf2885 | |
Emmanuel Viennet | 4915852d66 | |
Emmanuel Viennet | 85f00c7cb6 | |
Emmanuel Viennet | b8b3fbb324 | |
Emmanuel Viennet | dd93d952d7 | |
Emmanuel Viennet | 00c09b1eb8 | |
Emmanuel Viennet | 0e628273cf | |
pascal.bouron | 664d5483fc | |
Emmanuel Viennet | b4eab5fcbc | |
Emmanuel Viennet | c551634417 | |
pascal.bouron | efe8673e8a | |
Sébastien Lehmann | f17b10da3b | |
Emmanuel Viennet | cf18520e9c | |
Emmanuel Viennet | cda20c27b2 | |
Sébastien Lehmann | b7983a8d59 | |
Emmanuel Viennet | 47b3eec14b | |
Emmanuel Viennet | 9f6068caa2 | |
Emmanuel Viennet | 04277d1f57 | |
Emmanuel Viennet | f9d15da553 | |
Emmanuel Viennet | a7126990f0 | |
Emmanuel Viennet | 42d92cb998 | |
Emmanuel Viennet | 2d3d7d49fc | |
Emmanuel Viennet | 277e87add9 | |
Emmanuel Viennet | fffb07d612 | |
Emmanuel Viennet | afe9ae69a9 | |
Emmanuel Viennet | 16f953caf6 | |
iziram | f3b1b8a3cb | |
Emmanuel Viennet | 7adc7d824b | |
Emmanuel Viennet | cf900d2027 | |
Emmanuel Viennet | 1fa8375b11 | |
Emmanuel Viennet | bdefa111a7 | |
Emmanuel Viennet | c5b2df379e | |
Emmanuel Viennet | 3460b217dd | |
Emmanuel Viennet | 18aed44644 | |
Emmanuel Viennet | ec632dd43c | |
Emmanuel Viennet | acc1ecf906 | |
Emmanuel Viennet | 9566551e7e | |
Emmanuel Viennet | 7e1b0177f0 | |
Emmanuel Viennet | 8e6dc37a87 | |
Emmanuel Viennet | a4840f494b | |
Emmanuel Viennet | a28f58a443 | |
Emmanuel Viennet | 2a41cf972c | |
iziram | f96571f520 | |
iziram | 4df1bdda8e | |
Emmanuel Viennet | 6c88dfa722 | |
iziram | b9f3db91d4 | |
Emmanuel Viennet | 3e2631b94d | |
Emmanuel Viennet | 9251810814 | |
Emmanuel Viennet | 4c83c69f7c | |
Emmanuel Viennet | b09dc63fe3 | |
Emmanuel Viennet | 075d864de3 | |
Emmanuel Viennet | 6440ca4a1f | |
Emmanuel Viennet | 7d2d19f3a8 | |
Emmanuel Viennet | 882d131837 | |
Emmanuel Viennet | 3012fc465d | |
Emmanuel Viennet | 7069fb6e31 | |
Emmanuel Viennet | be2d7926bf | |
Emmanuel Viennet | bc6d9d5442 | |
Emmanuel Viennet | a0a6dbea00 | |
Emmanuel Viennet | 872e741d9f | |
Emmanuel Viennet | 5258a570a6 | |
Emmanuel Viennet | f0da8434a9 | |
Emmanuel Viennet | e995228ca7 | |
Emmanuel Viennet | e59fce5f6b | |
Emmanuel Viennet | 0d9338dc0a | |
Emmanuel Viennet | f1fd4d98d7 | |
Emmanuel Viennet | c6e35dd4cd | |
Emmanuel Viennet | cb8d313dc7 | |
Emmanuel Viennet | 3e3b09134d | |
Emmanuel Viennet | 7b3c50620b | |
Emmanuel Viennet | 63fb09348d | |
Emmanuel Viennet | 7a9dc11af3 | |
Sébastien Lehmann | d178c636bf | |
Emmanuel Viennet | c6a06266fa | |
Emmanuel Viennet | e1504adc03 | |
iziram | a9615bc077 | |
iziram | 3ff4abd19c | |
Emmanuel Viennet | 0a1a847044 | |
Emmanuel Viennet | f5442b924f | |
Emmanuel Viennet | bec4cd7978 | |
Emmanuel Viennet | f63fa43862 | |
Emmanuel Viennet | ca20c303f0 | |
Emmanuel Viennet | 014886c288 | |
Emmanuel Viennet | e2110f4abb | |
Emmanuel Viennet | ff12f4312e | |
Emmanuel Viennet | 930a96b984 | |
Emmanuel Viennet | 60fa12df81 | |
Emmanuel Viennet | 0324771aa2 | |
Emmanuel Viennet | 688fc5401f | |
Emmanuel Viennet | c1cbd6bce0 | |
Emmanuel Viennet | f2ffd69fe6 | |
Emmanuel Viennet | ba5b5cdb6f | |
Emmanuel Viennet | 51b0ca088c | |
Emmanuel Viennet | 9c618692d1 | |
Emmanuel Viennet | a0c33b3c19 | |
Emmanuel Viennet | ef1b28fe27 | |
Emmanuel Viennet | f246d9e82c | |
Emmanuel Viennet | cd36737460 | |
Emmanuel Viennet | acb8e6aab2 | |
Emmanuel Viennet | 8ef19b14c7 | |
Emmanuel Viennet | 7af381becc | |
Emmanuel Viennet | c9b4058717 | |
Emmanuel Viennet | 42b03dbdfa | |
Emmanuel Viennet | a87dbd9927 | |
Emmanuel Viennet | ae9aad0619 | |
Emmanuel Viennet | e38d4bde81 | |
Sébastien Lehmann | 25d1132a06 | |
Emmanuel Viennet | 4c730a6302 | |
Emmanuel Viennet | 287e4df74e | |
Emmanuel Viennet | 77348c2cdf | |
Emmanuel Viennet | f67a11519e | |
Emmanuel Viennet | f9a9c2088d | |
Emmanuel Viennet | afbb1fb0e2 | |
Emmanuel Viennet | 346701d91e | |
Emmanuel Viennet | f647ff1139 | |
Emmanuel Viennet | f5988b9e34 | |
Emmanuel Viennet | f318f35c1b | |
Emmanuel Viennet | 4626cb9a3e | |
Emmanuel Viennet | 0a58437fa9 | |
Emmanuel Viennet | c906cd7f16 | |
Emmanuel Viennet | ee86fba3d3 | |
Emmanuel Viennet | 5018298d12 | |
Emmanuel Viennet | 9d64caa749 | |
Emmanuel Viennet | 6f257dc80d | |
Emmanuel Viennet | 49d176c603 | |
Emmanuel Viennet | 559b66de8b | |
Emmanuel Viennet | d7f1114a42 | |
Emmanuel Viennet | a2ea7d7a02 | |
Emmanuel Viennet | f6d8de5a20 | |
Emmanuel Viennet | 2bf678ac50 | |
Jean-Marie PLACE | 63c0667694 | |
Jean-Marie PLACE | 3f7f4172b5 | |
Emmanuel Viennet | 528d5c8863 | |
Jean-Marie Place | 7b28e0ba6b | |
Emmanuel Viennet | 588f2f26eb | |
Emmanuel Viennet | 4940decf57 | |
Emmanuel Viennet | d055c17c6b | |
Emmanuel Viennet | ea0a49d837 | |
Emmanuel Viennet | 59a6ee3b3e | |
Emmanuel Viennet | 111634db99 | |
Emmanuel Viennet | 26dcc31ffb | |
Emmanuel Viennet | 18f4b9cd42 | |
Emmanuel Viennet | dbc9aab7c3 | |
Jean-Marie PLACE | 9c50d03dd8 | |
Emmanuel Viennet | 2d76cc0ad1 | |
iziram | b7fb8879df | |
Emmanuel Viennet | 3e0f43d5ea | |
Emmanuel Viennet | dcdd83d2e8 | |
Emmanuel Viennet | 4d453d5d14 | |
Emmanuel Viennet | 20b13b05cf | |
Emmanuel Viennet | a730bf759b | |
Jean-Marie PLACE | a63349382e | |
Emmanuel Viennet | dab6bad08f | |
Emmanuel Viennet | e435dd10db | |
Sébastien Lehmann | 95100ed429 | |
Emmanuel Viennet | 155a093635 | |
Jean-Marie PLACE | c85a51a8c5 | |
Emmanuel Viennet | d50107079b | |
Emmanuel Viennet | 9535ff1e91 | |
Emmanuel Viennet | f4d8f4dded | |
Emmanuel Viennet | ba003d7c02 | |
Emmanuel Viennet | 7ca3290357 | |
Emmanuel Viennet | 7cb98e3f31 | |
Emmanuel Viennet | 365e54f7e1 | |
Emmanuel Viennet | 9da5506361 | |
Emmanuel Viennet | 979359257b | |
Emmanuel Viennet | cc674b4e65 | |
Emmanuel Viennet | c103111aa1 | |
Emmanuel Viennet | b9d6688250 | |
Emmanuel Viennet | 93e54982b6 | |
Emmanuel Viennet | eefdd5458e | |
Emmanuel Viennet | 072d839b75 | |
Emmanuel Viennet | 0de35b5400 | |
Emmanuel Viennet | cfcb100ab7 | |
iziram | 1c48758940 | |
Emmanuel Viennet | 3bae99c5cd | |
Emmanuel Viennet | ae2f8a97fd | |
Emmanuel Viennet | 1598537f24 | |
Emmanuel Viennet | 557a0c3f6d | |
Emmanuel Viennet | fa84f9ed89 | |
Emmanuel Viennet | dac46b8366 | |
Emmanuel Viennet | a92d2a6edf | |
Emmanuel Viennet | caf88c5909 | |
Emmanuel Viennet | 4da21bf4d3 | |
Emmanuel Viennet | bdaf416ccb | |
Emmanuel Viennet | 066e03dae8 | |
Emmanuel Viennet | 1847250bab | |
Emmanuel Viennet | 1ce4ffecad | |
Emmanuel Viennet | 91e77dd2dc | |
Emmanuel Viennet | 7dcebf4b83 | |
Emmanuel Viennet | 10caea92ae | |
Emmanuel Viennet | 75c5256ba9 | |
Emmanuel Viennet | 678959c76a | |
Emmanuel Viennet | 77c0294c83 | |
Emmanuel Viennet | 8d72229e8b | |
Emmanuel Viennet | 84b02edd48 | |
Emmanuel Viennet | ec4aa0e26f | |
Emmanuel Viennet | 50e8f2b4fe | |
Emmanuel Viennet | 318f6f8a65 | |
Emmanuel Viennet | 91e508bf9f | |
Emmanuel Viennet | 036ce650c6 | |
Emmanuel Viennet | 37a8b3bb0b | |
Emmanuel Viennet | ad46a190ab | |
Emmanuel Viennet | f69ce75b1f | |
Emmanuel Viennet | 1813e3c7ce | |
Emmanuel Viennet | 7bbdff67a0 | |
Emmanuel Viennet | dcf0f73c1b | |
Emmanuel Viennet | 06cbd65365 | |
Emmanuel Viennet | 268b75d441 | |
Emmanuel Viennet | 0c5e338970 | |
Emmanuel Viennet | 2731a4728b | |
Emmanuel Viennet | 4f87f22586 | |
Emmanuel Viennet | a3e4c34745 | |
Emmanuel Viennet | 78bb9a706e | |
Emmanuel Viennet | d6be0e131f | |
Emmanuel Viennet | 9527240ea8 | |
Emmanuel Viennet | c6c7187c34 | |
Emmanuel Viennet | 453f11084b | |
Emmanuel Viennet | 6b29a205b6 |
|
@ -1,4 +1,8 @@
|
|||
[[MESSAGES CONTROL]
|
||||
|
||||
[MASTER]
|
||||
load-plugins=pylint_flask_sqlalchemy,pylint_flask
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# pylint and black disagree...
|
||||
disable=bad-continuation
|
||||
|
||||
|
|
65
README.md
65
README.md
|
@ -1,5 +1,4 @@
|
|||
i
|
||||
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
|
||||
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
|
||||
|
||||
(c) Emmanuel Viennet 1999 - 2022 (voir LICENCE.txt).
|
||||
|
||||
|
@ -9,39 +8,34 @@ Documentation utilisateur: <https://scodoc.org>
|
|||
|
||||
## Version ScoDoc 9
|
||||
|
||||
La version ScoDoc 9 est parue en septembre 2021.
|
||||
Elle représente une évolution majeure du projet, maintenant basé sur
|
||||
Flask (au lieu de Zope) et sur **python 3.9+**.
|
||||
La version ScoDoc 9 est parue en septembre 2021. Elle représente une évolution
|
||||
majeure du projet, maintenant basé sur Flask (au lieu de Zope) et sur **python
|
||||
3.9+**.
|
||||
|
||||
La version 9.0 s'efforce de reproduire presque à l'identique le fonctionnement
|
||||
La version 9.0 s'efforce de reproduire presque à l'identique le fonctionnement
|
||||
de ScoDoc7, avec des composants logiciels différents (Debian 11, Python 3,
|
||||
Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
|
||||
|
||||
### État actuel (dec 22)
|
||||
|
||||
- 9.4.x est en production
|
||||
- le prochain jalon est 9.5. Voir branches sur gitea.
|
||||
|
||||
### État actuel (26 jan 22)
|
||||
|
||||
- 9.1.5x (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf:
|
||||
- ancien module "Entreprises" (obsolète) et ajoute la gestion du BUT.
|
||||
|
||||
- 9.2 (branche dev92) est la version de développement.
|
||||
|
||||
|
||||
### Lignes de commandes
|
||||
|
||||
Voir [https://scodoc.org/GuideConfig](le guide de configuration).
|
||||
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
L'installation comporte les fichiers de l'application, sous `/opt/scodoc/`, et
|
||||
les fichiers locaux (archives, photos, configurations, logs) sous
|
||||
`/opt/scodoc-data`. Par ailleurs, il y a évidemment les bases de données
|
||||
postgresql et la configuration du système Linux.
|
||||
postgresql et la configuration du système Linux.
|
||||
|
||||
### Fichiers locaux
|
||||
Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`.
|
||||
Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous
|
||||
|
||||
Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`.
|
||||
Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous
|
||||
`/opt/scodoc-data/config`.
|
||||
|
||||
Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
|
||||
|
@ -62,7 +56,7 @@ Principaux contenus:
|
|||
|
||||
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian11)).
|
||||
|
||||
Puis remplacer `/opt/scodoc` par un clone du git.
|
||||
Puis remplacer `/opt/scodoc` par un clone du git.
|
||||
|
||||
sudo su
|
||||
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
|
||||
|
@ -76,7 +70,7 @@ Puis remplacer `/opt/scodoc` par un clone du git.
|
|||
|
||||
# Et donner ce répertoire à l'utilisateur scodoc:
|
||||
chown -R scodoc.scodoc /opt/scodoc
|
||||
|
||||
|
||||
Il faut ensuite installer l'environnement et le fichier de configuration:
|
||||
|
||||
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
|
||||
|
@ -100,14 +94,14 @@ Avant le premier lancement, créer cette base ainsi:
|
|||
flask db upgrade
|
||||
|
||||
Cette commande n'est nécessaire que la première fois (le contenu de la base
|
||||
est effacé au début de chaque test, mais son schéma reste) et aussi si des
|
||||
est effacé au début de chaque test, mais son schéma reste) et aussi si des
|
||||
migrations (changements de schéma) ont eu lieu dans le code.
|
||||
|
||||
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
|
||||
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
|
||||
scripts de tests:
|
||||
Lancer au préalable:
|
||||
|
||||
flask delete-dept TEST00 && flask create-dept TEST00
|
||||
flask delete-dept -fy TEST00 && flask create-dept TEST00
|
||||
|
||||
Puis dérouler les tests unitaires:
|
||||
|
||||
|
@ -117,24 +111,24 @@ Ou avec couverture (`pip install pytest-cov`)
|
|||
|
||||
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
|
||||
|
||||
|
||||
#### Utilisation des tests unitaires pour initialiser la base de dev
|
||||
On peut aussi utiliser les tests unitaires pour mettre la base
|
||||
de données de développement dans un état connu, par exemple pour éviter de
|
||||
recréer à la main étudiants et semestres quand on développe.
|
||||
|
||||
Il suffit de positionner une variable d'environnement indiquant la BD
|
||||
utilisée par les tests:
|
||||
On peut aussi utiliser les tests unitaires pour mettre la base de données de
|
||||
développement dans un état connu, par exemple pour éviter de recréer à la main
|
||||
étudiants et semestres quand on développe.
|
||||
|
||||
Il suffit de positionner une variable d'environnement indiquant la BD utilisée
|
||||
par les tests:
|
||||
|
||||
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
|
||||
|
||||
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
|
||||
normalement, par exemple:
|
||||
normalement, par exemple:
|
||||
|
||||
pytest tests/unit/test_sco_basic.py
|
||||
|
||||
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins)
|
||||
un utilisateur:
|
||||
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
|
||||
utilisateur:
|
||||
|
||||
flask user-password admin
|
||||
|
||||
|
@ -178,12 +172,10 @@ Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bie
|
|||
|
||||
pip install snakeviz
|
||||
|
||||
puis
|
||||
puis
|
||||
|
||||
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
|
||||
|
||||
|
||||
|
||||
# Paquet Debian 11
|
||||
|
||||
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
|
||||
|
@ -191,5 +183,4 @@ important est `postinst`qui se charge de configurer le système (install ou
|
|||
upgrade de scodoc9).
|
||||
|
||||
La préparation d'une release se fait à l'aide du script
|
||||
`tools/build_release.sh`.
|
||||
|
||||
`tools/build_release.sh`.
|
||||
|
|
117
app/__init__.py
117
app/__init__.py
|
@ -19,19 +19,26 @@ from flask import abort, flash, has_request_context, jsonify
|
|||
from flask import render_template
|
||||
from flask.json import JSONEncoder
|
||||
from flask.logging import default_handler
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
|
||||
from flask_bootstrap import Bootstrap
|
||||
from flask_caching import Cache
|
||||
from flask_login import LoginManager, current_user
|
||||
from flask_mail import Mail
|
||||
from flask_bootstrap import Bootstrap
|
||||
from flask_migrate import Migrate
|
||||
from flask_moment import Moment
|
||||
from flask_caching import Cache
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from jinja2 import select_autoescape
|
||||
import sqlalchemy
|
||||
|
||||
from flask_cas import CAS
|
||||
|
||||
from app.scodoc.sco_exceptions import (
|
||||
AccessDenied,
|
||||
ScoBugCatcher,
|
||||
ScoException,
|
||||
ScoGenError,
|
||||
ScoInvalidCSRF,
|
||||
ScoValueError,
|
||||
APIInvalidParams,
|
||||
)
|
||||
|
@ -60,11 +67,20 @@ cache = Cache(
|
|||
|
||||
|
||||
def handle_sco_value_error(exc):
|
||||
return render_template("sco_value_error.html", exc=exc), 404
|
||||
return render_template("sco_value_error.j2", exc=exc), 404
|
||||
|
||||
|
||||
def handle_access_denied(exc):
|
||||
return render_template("error_access_denied.html", exc=exc), 403
|
||||
return render_template("error_access_denied.j2", exc=exc), 403
|
||||
|
||||
|
||||
def handle_invalid_csrf(exc):
|
||||
"""Form submit with invalid CSRF token"""
|
||||
# logout user and go back to login page with an error message
|
||||
from app import auth
|
||||
|
||||
auth.logic.logout()
|
||||
return render_template("error_csrf.j2", exc=exc), 404
|
||||
|
||||
|
||||
def internal_server_error(exc):
|
||||
|
@ -74,7 +90,7 @@ def internal_server_error(exc):
|
|||
|
||||
return (
|
||||
render_template(
|
||||
"error_500.html",
|
||||
"error_500.j2",
|
||||
SCOVERSION=sco_version.SCOVERSION,
|
||||
date=datetime.datetime.now().isoformat(),
|
||||
exc=exc,
|
||||
|
@ -92,9 +108,12 @@ def handle_sco_bug(exc):
|
|||
"""Un bug, en général rare, sur lequel les dev cherchent des
|
||||
informations pour le corriger.
|
||||
"""
|
||||
Thread(
|
||||
target=_async_dump, args=(current_app._get_current_object(), request.url)
|
||||
).start()
|
||||
if current_app.config["TESTING"] or current_app.config["DEBUG"]:
|
||||
raise ScoException # for development servers only
|
||||
else:
|
||||
Thread(
|
||||
target=_async_dump, args=(current_app._get_current_object(), request.url)
|
||||
).start()
|
||||
|
||||
return internal_server_error(exc)
|
||||
|
||||
|
@ -119,7 +138,7 @@ def handle_invalid_usage(error):
|
|||
# JSON ENCODING
|
||||
class ScoDocJSONEncoder(JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, datetime.datetime):
|
||||
if isinstance(o, (datetime.datetime, datetime.date)):
|
||||
return o.isoformat()
|
||||
|
||||
return super().default(o)
|
||||
|
@ -127,7 +146,7 @@ class ScoDocJSONEncoder(JSONEncoder):
|
|||
|
||||
def render_raw_html(template_filename: str, **args) -> str:
|
||||
"""Load and render an HTML file _without_ using Flask
|
||||
Necessary for 503 error mesage, when DB is down and Flask may be broken.
|
||||
Necessary for 503 error message, when DB is down and Flask may be broken.
|
||||
"""
|
||||
template_path = os.path.join(
|
||||
current_app.config["SCODOC_DIR"],
|
||||
|
@ -142,7 +161,7 @@ def render_raw_html(template_filename: str, **args) -> str:
|
|||
|
||||
def postgresql_server_error(e):
|
||||
"""Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)"""
|
||||
return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503
|
||||
return render_raw_html("error_503.j2", SCOVERSION=sco_version.SCOVERSION), 503
|
||||
|
||||
|
||||
class LogRequestFormatter(logging.Formatter):
|
||||
|
@ -221,14 +240,16 @@ class ReverseProxied(object):
|
|||
|
||||
def create_app(config_class=DevConfig):
|
||||
app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static")
|
||||
from app.auth import cas
|
||||
|
||||
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
|
||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||
app.json_encoder = ScoDocJSONEncoder
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
# Evite de logguer toutes les requetes dans notre log
|
||||
logging.getLogger("werkzeug").disabled = True
|
||||
|
||||
app.config.from_object(config_class)
|
||||
# Evite de logguer toutes les requetes dans notre log
|
||||
logging.getLogger("werkzeug").disabled = True
|
||||
app.logger.setLevel(app.config["LOG_LEVEL"])
|
||||
|
||||
# Vérifie/crée lien sym pour les URL statiques
|
||||
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"
|
||||
|
@ -240,6 +261,7 @@ def create_app(config_class=DevConfig):
|
|||
migrate.init_app(app, db)
|
||||
login.init_app(app)
|
||||
mail.init_app(app)
|
||||
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
|
||||
bootstrap.init_app(app)
|
||||
moment.init_app(app)
|
||||
cache.init_app(app)
|
||||
|
@ -250,6 +272,7 @@ def create_app(config_class=DevConfig):
|
|||
app.register_error_handler(ScoGenError, handle_sco_value_error)
|
||||
app.register_error_handler(ScoValueError, handle_sco_value_error)
|
||||
app.register_error_handler(ScoBugCatcher, handle_sco_bug)
|
||||
app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf)
|
||||
app.register_error_handler(AccessDenied, handle_access_denied)
|
||||
app.register_error_handler(500, internal_server_error)
|
||||
app.register_error_handler(503, postgresql_server_error)
|
||||
|
@ -271,6 +294,9 @@ def create_app(config_class=DevConfig):
|
|||
from app.api import api_bp
|
||||
from app.api import api_web_bp
|
||||
|
||||
# Enable autoescaping of all templates, including .j2
|
||||
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
|
||||
|
||||
# https://scodoc.fr/ScoDoc
|
||||
app.register_blueprint(scodoc_bp)
|
||||
# https://scodoc.fr/ScoDoc/RT/Scolarite/...
|
||||
|
@ -370,6 +396,15 @@ def create_app(config_class=DevConfig):
|
|||
|
||||
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
|
||||
|
||||
from app.auth.cas import set_cas_configuration
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
set_cas_configuration(app)
|
||||
except sqlalchemy.exc.ProgrammingError:
|
||||
# Si la base n'a pas été upgradée (arrive durrant l'install)
|
||||
# il se peut que la table scodoc_site_config n'existe pas encore.
|
||||
pass
|
||||
return app
|
||||
|
||||
|
||||
|
@ -435,8 +470,6 @@ def initialize_scodoc_database(erase=False, create_all=False):
|
|||
SQL tables and functions.
|
||||
If erase is True, _erase_ all database content.
|
||||
"""
|
||||
from app import models
|
||||
|
||||
# - ERASE (the truncation sql function has been defined above)
|
||||
if erase:
|
||||
truncate_database()
|
||||
|
@ -463,6 +496,26 @@ def truncate_database():
|
|||
except:
|
||||
db.session.rollback()
|
||||
raise
|
||||
# Remet les compteurs (séquences sql) à zéro
|
||||
db.session.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
|
||||
DECLARE
|
||||
statements CURSOR FOR
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
ORDER BY sequence_name ;
|
||||
BEGIN
|
||||
FOR stmt IN statements LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || quote_ident(stmt.sequence_name) || ' RESTART;';
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
SELECT reset_sequences('scodoc');
|
||||
"""
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def clear_scodoc_cache():
|
||||
|
@ -480,12 +533,10 @@ def clear_scodoc_cache():
|
|||
|
||||
|
||||
# --------- Logging
|
||||
def log(msg: str, silent_test=True):
|
||||
def log(msg: str):
|
||||
"""log a message.
|
||||
If Flask app, use configured logger, else stderr.
|
||||
"""
|
||||
if silent_test and current_app and current_app.config["TESTING"]:
|
||||
return
|
||||
try:
|
||||
dept = getattr(g, "scodoc_dept", "")
|
||||
msg = f" ({dept}) {msg}"
|
||||
|
@ -510,10 +561,9 @@ def log_call_stack():
|
|||
|
||||
# Alarms by email:
|
||||
def send_scodoc_alarm(subject, txt):
|
||||
from app.scodoc import sco_preferences
|
||||
from app import email
|
||||
|
||||
sender = sco_preferences.get_preference("email_from_addr")
|
||||
sender = email.get_from_addr()
|
||||
email.send_email(subject, sender, ["exception@scodoc.org"], txt)
|
||||
|
||||
|
||||
|
@ -530,3 +580,22 @@ def scodoc_flash_status_messages():
|
|||
f"Mode test: mails redirigés vers {email_test_mode_address}",
|
||||
category="warning",
|
||||
)
|
||||
|
||||
|
||||
def critical_error(msg):
|
||||
"""Handle a critical error: flush all caches, display message to the user"""
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
log(f"\n*** CRITICAL ERROR: {msg}")
|
||||
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
|
||||
clear_scodoc_cache()
|
||||
raise ScoValueError(
|
||||
f"""
|
||||
Une erreur est survenue.
|
||||
|
||||
Si le problème persiste, merci de contacter le support ScoDoc via
|
||||
{scu.SCO_DISCORD_ASSISTANCE}
|
||||
|
||||
{msg}
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -2,13 +2,17 @@
|
|||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import request
|
||||
from flask import request, g, jsonify
|
||||
from app import db
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoException
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
api_web_bp = Blueprint("apiweb", __name__)
|
||||
|
||||
# HTTP ERROR STATUS
|
||||
API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client
|
||||
|
||||
|
||||
@api_bp.errorhandler(ScoException)
|
||||
@api_bp.errorhandler(404)
|
||||
|
@ -31,9 +35,26 @@ def requested_format(default_format="json", allowed_formats=None):
|
|||
return None
|
||||
|
||||
|
||||
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = 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, Formsemstre) -> join_cls
|
||||
|
||||
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
|
||||
"""
|
||||
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()
|
||||
|
||||
return jsonify(unique.to_dict(format_api=True))
|
||||
|
||||
|
||||
from app.api import tokens
|
||||
from app.api import (
|
||||
absences,
|
||||
assiduites,
|
||||
billets_absences,
|
||||
departements,
|
||||
etudiants,
|
||||
|
@ -41,6 +62,7 @@ from app.api import (
|
|||
formations,
|
||||
formsemestres,
|
||||
jury,
|
||||
justificatifs,
|
||||
logos,
|
||||
partitions,
|
||||
users,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Absences
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
from flask import jsonify
|
||||
|
||||
from app.api import api_bp as bp
|
||||
from app.api import api_bp as bp, API_CLIENT_ERROR
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import Identite
|
||||
|
|
|
@ -0,0 +1,646 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Assiduités
|
||||
"""
|
||||
from datetime import datetime
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
import app.scodoc.sco_assiduites as scass
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import db
|
||||
from app.api import api_bp as bp
|
||||
from app.api import api_web_bp
|
||||
from app.api import get_model_api_object
|
||||
from app.decorators import permission_required, scodoc
|
||||
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
|
||||
from app.auth.models import User
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_utils import json_error
|
||||
|
||||
|
||||
@bp.route("/assiduite/<int:assiduite_id>")
|
||||
@api_web_bp.route("/assiduite/<int:assiduite_id>")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def assiduite(assiduite_id: int = None):
|
||||
"""Retourne un objet assiduité à partir de son id
|
||||
|
||||
Exemple de résultat:
|
||||
{
|
||||
"assiduite_id": 1,
|
||||
"etudid": 2,
|
||||
"moduleimpl_id": 3,
|
||||
"date_debut": "2022-10-31T08:00+01:00",
|
||||
"date_fin": "2022-10-31T10:00+01:00",
|
||||
"etat": "retard",
|
||||
"desc": "une description",
|
||||
"user_id: 1 or null,
|
||||
"est_just": False or True,
|
||||
}
|
||||
"""
|
||||
|
||||
return get_model_api_object(Assiduite, assiduite_id, Identite)
|
||||
|
||||
|
||||
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
|
||||
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
|
||||
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
|
||||
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def count_assiduites(etudid: int = None, with_query: bool = False):
|
||||
"""
|
||||
Retourne le nombre d'assiduités d'un étudiant
|
||||
chemin : /assiduites/<int:etudid>/count
|
||||
|
||||
Un filtrage peut être donné avec une query
|
||||
chemin : /assiduites/<int:etudid>/count/query?
|
||||
|
||||
Les différents filtres :
|
||||
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
|
||||
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
|
||||
ex: .../query?type=heure
|
||||
Comportement par défaut : compte le nombre d'assiduité enregistrée
|
||||
|
||||
Etat (etat de l'étudiant -> absent, present ou retard):
|
||||
query?etat=[- liste des états séparé par une virgule -]
|
||||
ex: .../query?etat=present,retard
|
||||
Date debut
|
||||
(date de début de l'assiduité, sont affichés les assiduités
|
||||
dont la date de début est supérieur ou égale à la valeur donnée):
|
||||
query?date_debut=[- date au format iso -]
|
||||
ex: query?date_debut=2022-11-03T08:00+01:00
|
||||
Date fin
|
||||
(date de fin de l'assiduité, sont affichés les assiduités
|
||||
dont la date de fin est inférieure ou égale à la valeur donnée):
|
||||
query?date_fin=[- date au format iso -]
|
||||
ex: query?date_fin=2022-11-03T10:00+01:00
|
||||
Moduleimpl_id (l'id du module concerné par l'assiduité):
|
||||
query?moduleimpl_id=[- int ou vide -]
|
||||
ex: query?moduleimpl_id=1234
|
||||
query?moduleimpl_od=
|
||||
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
|
||||
query?formsemestre_id=[int]
|
||||
ex query?formsemestre_id=3
|
||||
user_id (l'id de l'auteur de l'assiduité)
|
||||
query?user_id=[int]
|
||||
ex query?user_id=3
|
||||
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
|
||||
query?est_just=[bool]
|
||||
query?est_just=f
|
||||
query?est_just=t
|
||||
|
||||
|
||||
|
||||
"""
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
etud: Identite = query.first_or_404(etudid)
|
||||
filtered: dict[str, object] = {}
|
||||
metric: str = "all"
|
||||
|
||||
if with_query:
|
||||
metric, filtered = _count_manager(request)
|
||||
|
||||
return jsonify(
|
||||
scass.get_assiduites_stats(
|
||||
assiduites=etud.assiduites, metric=metric, filtered=filtered
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
|
||||
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
|
||||
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
|
||||
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def assiduites(etudid: int = None, with_query: bool = False):
|
||||
"""
|
||||
Retourne toutes les assiduités d'un étudiant
|
||||
chemin : /assiduites/<int:etudid>
|
||||
|
||||
Un filtrage peut être donné avec une query
|
||||
chemin : /assiduites/<int:etudid>/query?
|
||||
|
||||
Les différents filtres :
|
||||
Etat (etat de l'étudiant -> absent, present ou retard):
|
||||
query?etat=[- liste des états séparé par une virgule -]
|
||||
ex: .../query?etat=present,retard
|
||||
Date debut
|
||||
(date de début de l'assiduité, sont affichés les assiduités
|
||||
dont la date de début est supérieur ou égale à la valeur donnée):
|
||||
query?date_debut=[- date au format iso -]
|
||||
ex: query?date_debut=2022-11-03T08:00+01:00
|
||||
Date fin
|
||||
(date de fin de l'assiduité, sont affichés les assiduités
|
||||
dont la date de fin est inférieure ou égale à la valeur donnée):
|
||||
query?date_fin=[- date au format iso -]
|
||||
ex: query?date_fin=2022-11-03T10:00+01:00
|
||||
Moduleimpl_id (l'id du module concerné par l'assiduité):
|
||||
query?moduleimpl_id=[- int ou vide -]
|
||||
ex: query?moduleimpl_id=1234
|
||||
query?moduleimpl_od=
|
||||
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
|
||||
query?formsemstre_id=[int]
|
||||
ex query?formsemestre_id=3
|
||||
user_id (l'id de l'auteur de l'assiduité)
|
||||
query?user_id=[int]
|
||||
ex query?user_id=3
|
||||
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
|
||||
query?est_just=[bool]
|
||||
query?est_just=f
|
||||
query?est_just=t
|
||||
|
||||
|
||||
"""
|
||||
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
etud: Identite = query.first_or_404(etudid)
|
||||
assiduites_query = etud.assiduites
|
||||
|
||||
if with_query:
|
||||
assiduites_query = _filter_manager(request, assiduites_query)
|
||||
|
||||
data_set: list[dict] = []
|
||||
for ass in assiduites_query.all():
|
||||
data = ass.to_dict(format_api=True)
|
||||
data_set.append(data)
|
||||
|
||||
return jsonify(data_set)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
|
||||
)
|
||||
@bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/query",
|
||||
defaults={"with_query": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/query",
|
||||
defaults={"with_query": True},
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
|
||||
"""Retourne toutes les assiduités du formsemestre"""
|
||||
formsemestre: FormSemestre = None
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
|
||||
|
||||
if formsemestre is None:
|
||||
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
|
||||
|
||||
assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre)
|
||||
|
||||
if with_query:
|
||||
assiduites_query = _filter_manager(request, assiduites_query)
|
||||
|
||||
data_set: list[dict] = []
|
||||
for ass in assiduites_query.all():
|
||||
data = ass.to_dict(format_api=True)
|
||||
data_set.append(data)
|
||||
|
||||
return jsonify(data_set)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/count",
|
||||
defaults={"with_query": False},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/count",
|
||||
defaults={"with_query": False},
|
||||
)
|
||||
@bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
|
||||
defaults={"with_query": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
|
||||
defaults={"with_query": True},
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def count_assiduites_formsemestre(
|
||||
formsemestre_id: int = None, with_query: bool = False
|
||||
):
|
||||
"""Comptage des assiduités du formsemestre"""
|
||||
formsemestre: FormSemestre = None
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
|
||||
|
||||
if formsemestre is None:
|
||||
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
|
||||
|
||||
etuds = formsemestre.etuds.all()
|
||||
etuds_id = [etud.id for etud in etuds]
|
||||
|
||||
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
|
||||
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
|
||||
metric: str = "all"
|
||||
filtered: dict = {}
|
||||
if with_query:
|
||||
metric, filtered = _count_manager(request)
|
||||
|
||||
return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered))
|
||||
|
||||
|
||||
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
|
||||
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def assiduite_create(etudid: int = None):
|
||||
"""
|
||||
Création d'une assiduité pour l'étudiant (etudid)
|
||||
La requête doit avoir un content type "application/json":
|
||||
[
|
||||
{
|
||||
"date_debut": str,
|
||||
"date_fin": str,
|
||||
"etat": str,
|
||||
},
|
||||
{
|
||||
"date_debut": str,
|
||||
"date_fin": str,
|
||||
"etat": str,
|
||||
"moduleimpl_id": int,
|
||||
"desc":str,
|
||||
}
|
||||
...
|
||||
]
|
||||
|
||||
"""
|
||||
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
|
||||
|
||||
create_list: list[object] = request.get_json(force=True)
|
||||
|
||||
if not isinstance(create_list, list):
|
||||
return json_error(404, "Le contenu envoyé n'est pas une liste")
|
||||
|
||||
errors: dict[int, str] = {}
|
||||
success: dict[int, object] = {}
|
||||
for i, data in enumerate(create_list):
|
||||
code, obj = _create_singular(data, etud)
|
||||
if code == 404:
|
||||
errors[i] = obj
|
||||
else:
|
||||
success[i] = obj
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"errors": errors, "success": success})
|
||||
|
||||
|
||||
def _create_singular(
|
||||
data: dict,
|
||||
etud: Identite,
|
||||
) -> tuple[int, object]:
|
||||
errors: list[str] = []
|
||||
|
||||
# -- vérifications de l'objet json --
|
||||
# cas 1 : ETAT
|
||||
etat = data.get("etat", None)
|
||||
if etat is None:
|
||||
errors.append("param 'etat': manquant")
|
||||
elif not scu.EtatAssiduite.contains(etat):
|
||||
errors.append("param 'etat': invalide")
|
||||
|
||||
etat = scu.EtatAssiduite.get(etat)
|
||||
|
||||
# cas 2 : date_debut
|
||||
date_debut = data.get("date_debut", None)
|
||||
if date_debut is None:
|
||||
errors.append("param 'date_debut': manquant")
|
||||
deb = scu.is_iso_formated(date_debut, convert=True)
|
||||
if deb is None:
|
||||
errors.append("param 'date_debut': format invalide")
|
||||
|
||||
# cas 3 : date_fin
|
||||
date_fin = data.get("date_fin", None)
|
||||
if date_fin is None:
|
||||
errors.append("param 'date_fin': manquant")
|
||||
fin = scu.is_iso_formated(date_fin, convert=True)
|
||||
if fin is None:
|
||||
errors.append("param 'date_fin': format invalide")
|
||||
|
||||
# cas 4 : moduleimpl_id
|
||||
|
||||
moduleimpl_id = data.get("moduleimpl_id", False)
|
||||
moduleimpl: ModuleImpl = None
|
||||
|
||||
if moduleimpl_id is not False:
|
||||
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
|
||||
if moduleimpl is None:
|
||||
errors.append("param 'moduleimpl_id': invalide")
|
||||
|
||||
# cas 5 : desc
|
||||
|
||||
desc: str = data.get("desc", None)
|
||||
|
||||
if errors:
|
||||
err: str = ", ".join(errors)
|
||||
return (404, err)
|
||||
|
||||
# TOUT EST OK
|
||||
try:
|
||||
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
|
||||
date_debut=deb,
|
||||
date_fin=fin,
|
||||
etat=etat,
|
||||
etud=etud,
|
||||
moduleimpl=moduleimpl,
|
||||
description=desc,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
db.session.add(nouv_assiduite)
|
||||
db.session.commit()
|
||||
return (200, {"assiduite_id": nouv_assiduite.id})
|
||||
except ScoValueError as excp:
|
||||
return (
|
||||
404,
|
||||
excp.args[0],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/assiduite/delete", methods=["POST"])
|
||||
@api_web_bp.route("/assiduite/delete", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def assiduite_delete():
|
||||
"""
|
||||
Suppression d'une assiduité à partir de son id
|
||||
|
||||
Forme des données envoyées :
|
||||
|
||||
[
|
||||
<assiduite_id:int>,
|
||||
...
|
||||
]
|
||||
|
||||
|
||||
"""
|
||||
assiduites_list: list[int] = request.get_json(force=True)
|
||||
if not isinstance(assiduites_list, list):
|
||||
return json_error(404, "Le contenu envoyé n'est pas une liste")
|
||||
|
||||
output = {"errors": {}, "success": {}}
|
||||
|
||||
for i, ass in enumerate(assiduites_list):
|
||||
code, msg = _delete_singular(ass, db)
|
||||
if code == 404:
|
||||
output["errors"][f"{i}"] = msg
|
||||
else:
|
||||
output["success"][f"{i}"] = {"OK": True}
|
||||
db.session.commit()
|
||||
return jsonify(output)
|
||||
|
||||
|
||||
def _delete_singular(assiduite_id: int, database):
|
||||
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
|
||||
if assiduite_unique is None:
|
||||
return (404, "Assiduite non existante")
|
||||
database.session.delete(assiduite_unique)
|
||||
return (200, "OK")
|
||||
|
||||
|
||||
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
|
||||
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def assiduite_edit(assiduite_id: int):
|
||||
"""
|
||||
Edition d'une assiduité à partir de son id
|
||||
La requête doit avoir un content type "application/json":
|
||||
{
|
||||
"etat"?: str,
|
||||
"moduleimpl_id"?: int
|
||||
"desc"?: str
|
||||
"est_just"?: bool
|
||||
}
|
||||
"""
|
||||
assiduite_unique: Assiduite = Assiduite.query.filter_by(
|
||||
id=assiduite_id
|
||||
).first_or_404()
|
||||
errors: list[str] = []
|
||||
data = request.get_json(force=True)
|
||||
|
||||
# Vérifications de data
|
||||
|
||||
# Cas 1 : Etat
|
||||
if data.get("etat") is not None:
|
||||
etat = scu.EtatAssiduite.get(data.get("etat"))
|
||||
if etat is None:
|
||||
errors.append("param 'etat': invalide")
|
||||
else:
|
||||
assiduite_unique.etat = etat
|
||||
|
||||
# Cas 2 : Moduleimpl_id
|
||||
moduleimpl_id = data.get("moduleimpl_id", False)
|
||||
moduleimpl: ModuleImpl = None
|
||||
|
||||
if moduleimpl_id is not False:
|
||||
if moduleimpl_id is not None:
|
||||
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
|
||||
if moduleimpl is None:
|
||||
errors.append("param 'moduleimpl_id': invalide")
|
||||
else:
|
||||
if not moduleimpl.est_inscrit(
|
||||
Identite.query.filter_by(id=assiduite_unique.etudid).first()
|
||||
):
|
||||
errors.append("param 'moduleimpl_id': etud non inscrit")
|
||||
else:
|
||||
assiduite_unique.moduleimpl_id = moduleimpl_id
|
||||
else:
|
||||
assiduite_unique.moduleimpl_id = moduleimpl_id
|
||||
|
||||
# Cas 3 : desc
|
||||
desc = data.get("desc", False)
|
||||
if desc is not False:
|
||||
assiduite_unique.desc = desc
|
||||
|
||||
# Cas 4 : est_just
|
||||
est_just = data.get("est_just")
|
||||
if est_just is not None:
|
||||
if not isinstance(est_just, bool):
|
||||
errors.append("param 'est_just' : booléen non reconnu")
|
||||
else:
|
||||
assiduite_unique.est_just = est_just
|
||||
|
||||
if errors:
|
||||
err: str = ", ".join(errors)
|
||||
return json_error(404, err)
|
||||
|
||||
db.session.add(assiduite_unique)
|
||||
db.session.commit()
|
||||
return jsonify({"OK": True})
|
||||
|
||||
|
||||
# -- Utils --
|
||||
|
||||
|
||||
def _count_manager(requested) -> tuple[str, dict]:
|
||||
"""
|
||||
Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête
|
||||
"""
|
||||
filtered: dict = {}
|
||||
# cas 1 : etat assiduite
|
||||
etat = requested.args.get("etat")
|
||||
if etat is not None:
|
||||
filtered["etat"] = etat
|
||||
|
||||
# cas 2 : date de début
|
||||
deb = requested.args.get("date_debut", "").replace(" ", "+")
|
||||
deb: datetime = scu.is_iso_formated(deb, True)
|
||||
if deb is not None:
|
||||
filtered["date_debut"] = deb
|
||||
|
||||
# cas 3 : date de fin
|
||||
fin = requested.args.get("date_fin", "").replace(" ", "+")
|
||||
fin = scu.is_iso_formated(fin, True)
|
||||
|
||||
if fin is not None:
|
||||
filtered["date_fin"] = fin
|
||||
|
||||
# cas 4 : moduleimpl_id
|
||||
module = requested.args.get("moduleimpl_id", False)
|
||||
try:
|
||||
if module is False:
|
||||
raise ValueError
|
||||
if module != "":
|
||||
module = int(module)
|
||||
else:
|
||||
module = None
|
||||
except ValueError:
|
||||
module = False
|
||||
|
||||
if module is not False:
|
||||
filtered["moduleimpl_id"] = module
|
||||
|
||||
# cas 5 : formsemestre_id
|
||||
formsemestre_id = requested.args.get("formsemestre_id")
|
||||
|
||||
if formsemestre_id is not None:
|
||||
formsemestre: FormSemestre = None
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
|
||||
filtered["formsemestre"] = formsemestre
|
||||
|
||||
# cas 6 : type
|
||||
metric = requested.args.get("metric", "all")
|
||||
|
||||
# cas 7 : est_just
|
||||
|
||||
est_just: str = requested.args.get("est_just")
|
||||
if est_just is not None:
|
||||
trues: tuple[str] = ("v", "t", "vrai", "true")
|
||||
falses: tuple[str] = ("f", "faux", "false")
|
||||
|
||||
if est_just.lower() in trues:
|
||||
filtered["est_just"] = True
|
||||
elif est_just.lower() in falses:
|
||||
filtered["est_just"] = False
|
||||
|
||||
# cas 8 : user_id
|
||||
|
||||
user_id = requested.args.get("user_id", False)
|
||||
if user_id is not False:
|
||||
filtered["user_id"] = user_id
|
||||
|
||||
return (metric, filtered)
|
||||
|
||||
|
||||
def _filter_manager(requested, assiduites_query: Assiduite):
|
||||
"""
|
||||
Retourne les assiduites entrées filtrées en fonction de la request
|
||||
"""
|
||||
# cas 1 : etat assiduite
|
||||
etat = requested.args.get("etat")
|
||||
if etat is not None:
|
||||
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
|
||||
|
||||
# cas 2 : date de début
|
||||
deb = requested.args.get("date_debut", "").replace(" ", "+")
|
||||
deb: datetime = scu.is_iso_formated(deb, True)
|
||||
|
||||
# cas 3 : date de fin
|
||||
fin = requested.args.get("date_fin", "").replace(" ", "+")
|
||||
fin = scu.is_iso_formated(fin, True)
|
||||
|
||||
if (deb, fin) != (None, None):
|
||||
assiduites_query: Assiduite = scass.filter_by_date(
|
||||
assiduites_query, Assiduite, deb, fin
|
||||
)
|
||||
|
||||
# cas 4 : moduleimpl_id
|
||||
module = requested.args.get("moduleimpl_id", False)
|
||||
try:
|
||||
if module is False:
|
||||
raise ValueError
|
||||
if module != "":
|
||||
module = int(module)
|
||||
else:
|
||||
module = None
|
||||
except ValueError:
|
||||
module = False
|
||||
|
||||
if module is not False:
|
||||
assiduites_query = scass.filter_by_module_impl(assiduites_query, module)
|
||||
|
||||
# cas 5 : formsemestre_id
|
||||
formsemestre_id = requested.args.get("formsemestre_id")
|
||||
|
||||
if formsemestre_id is not None:
|
||||
formsemestre: FormSemestre = None
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
|
||||
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
|
||||
|
||||
# cas 6 : est_just
|
||||
|
||||
est_just: str = requested.args.get("est_just")
|
||||
if est_just is not None:
|
||||
trues: tuple[str] = ("v", "t", "vrai", "true")
|
||||
falses: tuple[str] = ("f", "faux", "false")
|
||||
|
||||
if est_just.lower() in trues:
|
||||
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
|
||||
assiduites_query, True
|
||||
)
|
||||
elif est_just.lower() in falses:
|
||||
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
|
||||
assiduites_query, False
|
||||
)
|
||||
|
||||
# cas 8 : user_id
|
||||
|
||||
user_id = requested.args.get("user_id", False)
|
||||
if user_id is not False:
|
||||
assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id)
|
||||
|
||||
return assiduites_query
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -11,7 +11,6 @@
|
|||
from flask import g, jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.decorators import scodoc, permission_required
|
||||
|
@ -48,12 +47,9 @@ def billets_absence_create():
|
|||
justified = data.get("justified", False)
|
||||
if None in (etudid, abs_begin, abs_end):
|
||||
return json_error(
|
||||
404, message="Paramètre manquant: etudid, abs_bein, abs_end requis"
|
||||
404, message="Paramètre manquant: etudid, abs_begin, abs_end requis"
|
||||
)
|
||||
query = Identite.query.filter_by(etudid=etudid)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
etud = query.first_or_404()
|
||||
etud = Identite.get_etud(etudid)
|
||||
billet = BilletAbsence(
|
||||
etudid=etud.id,
|
||||
abs_begin=abs_begin,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -10,13 +10,14 @@
|
|||
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
|
||||
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from flask import jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp
|
||||
from app import db
|
||||
from app.api import api_bp as bp, API_CLIENT_ERROR
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.models import Departement, FormSemestre
|
||||
|
@ -42,7 +43,7 @@ def get_departement(dept_ident: str) -> Departement:
|
|||
@permission_required(Permission.ScoView)
|
||||
def departements_list():
|
||||
"""Liste les départements"""
|
||||
return jsonify([dept.to_dict() for dept in Departement.query])
|
||||
return jsonify([dept.to_dict(with_dept_name=True) for dept in Departement.query])
|
||||
|
||||
|
||||
@bp.route("/departements_ids")
|
||||
|
@ -66,13 +67,14 @@ def departement(acronym: str):
|
|||
{
|
||||
"id": 1,
|
||||
"acronym": "TAPI",
|
||||
"dept_name" : "TEST",
|
||||
"description": null,
|
||||
"visible": true,
|
||||
"date_creation": "Fri, 15 Apr 2022 12:19:28 GMT"
|
||||
}
|
||||
"""
|
||||
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
|
||||
return jsonify(dept.to_dict())
|
||||
return jsonify(dept.to_dict(with_dept_name=True))
|
||||
|
||||
|
||||
@bp.route("/departement/id/<int:dept_id>")
|
||||
|
@ -103,12 +105,12 @@ def departement_create():
|
|||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
acronym = str(data.get("acronym", ""))
|
||||
if not acronym:
|
||||
return json_error(404, "missing acronym")
|
||||
return json_error(API_CLIENT_ERROR, "missing acronym")
|
||||
visible = bool(data.get("visible", True))
|
||||
try:
|
||||
dept = departements.create_dept(acronym, visible=visible)
|
||||
except ScoValueError as exc:
|
||||
return json_error(404, exc.args[0] if exc.args else "")
|
||||
return json_error(500, exc.args[0] if exc.args else "")
|
||||
return jsonify(dept.to_dict())
|
||||
|
||||
|
||||
|
@ -128,7 +130,7 @@ def departement_edit(acronym):
|
|||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
visible = bool(data.get("visible", None))
|
||||
if visible is None:
|
||||
return json_error(404, "missing argument: visible")
|
||||
return json_error(API_CLIENT_ERROR, "missing argument: visible")
|
||||
visible = bool(visible)
|
||||
dept.visible = visible
|
||||
db.session.add(dept)
|
||||
|
@ -256,15 +258,18 @@ def dept_formsemestres_courants(acronym: str):
|
|||
]
|
||||
"""
|
||||
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
|
||||
|
||||
date_courante = request.args.get("date_courante")
|
||||
if date_courante:
|
||||
test_date = datetime.fromisoformat(date_courante)
|
||||
else:
|
||||
test_date = app.db.func.now()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
FormSemestre.date_debut <= app.db.func.now(),
|
||||
FormSemestre.date_fin >= app.db.func.now(),
|
||||
FormSemestre.date_debut <= test_date,
|
||||
FormSemestre.date_fin >= test_date,
|
||||
)
|
||||
|
||||
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
|
||||
return jsonify([d.to_dict_api() for d in formsemestres])
|
||||
|
||||
|
||||
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
|
||||
|
@ -277,12 +282,16 @@ def dept_formsemestres_courants_by_id(dept_id: int):
|
|||
"""
|
||||
# Le département, spécifié par un id ou un acronyme
|
||||
dept = Departement.query.get_or_404(dept_id)
|
||||
|
||||
date_courante = request.args.get("date_courante")
|
||||
if date_courante:
|
||||
test_date = datetime.fromisoformat(date_courante)
|
||||
else:
|
||||
test_date = app.db.func.now()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
FormSemestre.date_debut <= app.db.func.now(),
|
||||
FormSemestre.date_fin >= app.db.func.now(),
|
||||
FormSemestre.date_debut <= test_date,
|
||||
FormSemestre.date_fin >= test_date,
|
||||
)
|
||||
|
||||
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
|
||||
return jsonify([d.to_dict_api() for d in formsemestres])
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
API : accès aux étudiants
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, jsonify
|
||||
from flask import abort, g, jsonify, request
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc, or_
|
||||
|
@ -75,11 +76,16 @@ def etudiants_courants(long=False):
|
|||
|
||||
"""
|
||||
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
|
||||
date_courante = request.args.get("date_courante")
|
||||
if date_courante:
|
||||
test_date = datetime.fromisoformat(date_courante)
|
||||
else:
|
||||
test_date = app.db.func.now()
|
||||
etuds = Identite.query.filter(
|
||||
Identite.id == FormSemestreInscription.etudid,
|
||||
FormSemestreInscription.formsemestre_id == FormSemestre.id,
|
||||
FormSemestre.date_debut <= app.db.func.now(),
|
||||
FormSemestre.date_fin >= app.db.func.now(),
|
||||
FormSemestre.date_debut <= test_date,
|
||||
FormSemestre.date_fin >= test_date,
|
||||
)
|
||||
if not None in allowed_depts:
|
||||
# restreint aux départements autorisés:
|
||||
|
@ -204,160 +210,88 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
|
|||
|
||||
|
||||
@bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
|
||||
defaults={"pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": True},
|
||||
)
|
||||
@bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": True},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
|
||||
defaults={"pdf": True, "with_img_signatures_pdf": False},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": False},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
|
||||
defaults={"pdf": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "long", "pdf": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": False},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
|
||||
methods=["GET"],
|
||||
defaults={"version": "short", "pdf": True},
|
||||
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
|
||||
defaults={"pdf": True, "with_img_signatures_pdf": False},
|
||||
)
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def etudiant_bulletin_semestre(
|
||||
formsemestre_id,
|
||||
etudid: int = None,
|
||||
nip: str = None,
|
||||
ine: str = None,
|
||||
version="long",
|
||||
def bulletin(
|
||||
code_type: str = "etudid",
|
||||
code: str = None,
|
||||
formsemestre_id: int = None,
|
||||
version: str = "long",
|
||||
pdf: bool = False,
|
||||
with_img_signatures_pdf: bool = True,
|
||||
):
|
||||
"""
|
||||
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
|
||||
|
||||
formsemestre_id : l'id d'un formsemestre
|
||||
etudid : l'etudid d'un étudiant
|
||||
nip : le code nip d'un étudiant
|
||||
ine : le code ine d'un étudiant
|
||||
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
|
||||
code_type : "etudid", "nip" ou "ine"
|
||||
code : valeur du code INE, NIP ou etudid, selon code_type.
|
||||
version : type de bulletin (par défaut, "long"): short, long, long_mat
|
||||
pdf : si spécifié, bulletin au format PDF (et non JSON).
|
||||
|
||||
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
|
||||
"""
|
||||
if version == "pdf":
|
||||
version = "long"
|
||||
pdf = True
|
||||
# return f"{code_type}={code}, version={version}, pdf={pdf}"
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
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 non trouve")
|
||||
if etudid is not None:
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif nip is not None:
|
||||
query = Identite.query.filter_by(code_nip=nip, dept_id=dept.id)
|
||||
elif ine is not None:
|
||||
query = Identite.query.filter_by(code_ine=ine, dept_id=dept.id)
|
||||
else:
|
||||
return json_error(404, message="parametre manquant")
|
||||
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")
|
||||
|
||||
app.set_sco_dept(dept.acronym)
|
||||
|
||||
if pdf:
|
||||
pdf_response, _ = do_formsemestre_bulletinetud(
|
||||
formsemestre, etud.id, version=version, format="pdf"
|
||||
formsemestre,
|
||||
etud,
|
||||
version=version,
|
||||
format="pdf",
|
||||
with_img_signatures_pdf=with_img_signatures_pdf,
|
||||
)
|
||||
return pdf_response
|
||||
|
||||
return sco_bulletins.get_formsemestre_bulletin_etud_json(
|
||||
formsemestre, etud, version=version
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -15,13 +15,50 @@ import app
|
|||
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.models import Evaluation, ModuleImpl, FormSemestre
|
||||
from app.scodoc import sco_evaluation_db
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
@bp.route("/evaluation/<int:evaluation_id>")
|
||||
@api_web_bp.route("/evaluation/<int:evaluation_id>")
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def evaluation(evaluation_id: int):
|
||||
"""Description d'une évaluation.
|
||||
|
||||
{
|
||||
'coefficient': 1.0,
|
||||
'date_debut': '2016-01-04T08:30:00',
|
||||
'date_fin': '2016-01-04T12:30:00',
|
||||
'description': 'TP NI9219 Température',
|
||||
'evaluation_type': 0,
|
||||
'id': 15797,
|
||||
'moduleimpl_id': 1234,
|
||||
'note_max': 20.0,
|
||||
'numero': 3,
|
||||
'poids': {
|
||||
'UE1.1': 1.0,
|
||||
'UE1.2': 1.0,
|
||||
'UE1.3': 1.0
|
||||
},
|
||||
'publish_incomplete': False,
|
||||
'visi_bulletin': True
|
||||
}
|
||||
"""
|
||||
query = Evaluation.query.filter_by(id=evaluation_id)
|
||||
if g.scodoc_dept:
|
||||
query = (
|
||||
query.join(ModuleImpl)
|
||||
.join(FormSemestre)
|
||||
.filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
e = query.first_or_404()
|
||||
return jsonify(e.to_dict_api())
|
||||
|
||||
|
||||
@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
|
||||
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
|
||||
@login_required
|
||||
|
@ -33,39 +70,16 @@ def evaluations(moduleimpl_id: int):
|
|||
|
||||
moduleimpl_id : l'id d'un moduleimpl
|
||||
|
||||
Exemple de résultat :
|
||||
[
|
||||
{
|
||||
"moduleimpl_id": 1,
|
||||
"jour": "20/04/2022",
|
||||
"heure_debut": "08h00",
|
||||
"description": "eval1",
|
||||
"coefficient": 1.0,
|
||||
"publish_incomplete": false,
|
||||
"numero": 0,
|
||||
"id": 1,
|
||||
"heure_fin": "09h00",
|
||||
"note_max": 20.0,
|
||||
"visibulletin": true,
|
||||
"evaluation_type": 0,
|
||||
"evaluation_id": 1,
|
||||
"jouriso": "2022-04-20",
|
||||
"duree": "1h",
|
||||
"descrheure": " de 08h00 à 09h00",
|
||||
"matin": 1,
|
||||
"apresmidi": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
Exemple de résultat : voir /evaluation
|
||||
"""
|
||||
query = Evaluation.query.filter_by(id=moduleimpl_id)
|
||||
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 jsonify([d.to_dict() for d in query])
|
||||
return jsonify([e.to_dict_api() for e in query])
|
||||
|
||||
|
||||
@bp.route("/evaluation/<int:evaluation_id>/notes")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -11,7 +11,7 @@ from flask import g, jsonify, request
|
|||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.comp import res_sem
|
||||
|
@ -22,6 +22,8 @@ from app.models import (
|
|||
Evaluation,
|
||||
FormSemestre,
|
||||
FormSemestreEtape,
|
||||
FormSemestreInscription,
|
||||
Identite,
|
||||
ModuleImpl,
|
||||
NotesNotes,
|
||||
)
|
||||
|
@ -30,6 +32,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
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>")
|
||||
|
@ -95,11 +98,14 @@ def formsemestres_query():
|
|||
annee_scolaire : année de début de l'année scolaire
|
||||
dept_acronym : acronyme du département (eg "RT")
|
||||
dept_id : id du département
|
||||
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
|
||||
"""
|
||||
etape_apo = request.args.get("etape_apo")
|
||||
annee_scolaire = request.args.get("annee_scolaire")
|
||||
dept_acronym = request.args.get("dept_acronym")
|
||||
dept_id = request.args.get("dept_id")
|
||||
nip = request.args.get("nip")
|
||||
ine = request.args.get("ine")
|
||||
formsemestres = FormSemestre.query
|
||||
if g.scodoc_dept:
|
||||
formsemestres = formsemestres.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
@ -107,7 +113,7 @@ def formsemestres_query():
|
|||
try:
|
||||
annee_scolaire_int = int(annee_scolaire)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid annee_scolaire: not int")
|
||||
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)
|
||||
formsemestres = formsemestres.filter(
|
||||
|
@ -119,22 +125,36 @@ def formsemestres_query():
|
|||
try:
|
||||
dept_id = int(dept_id)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid dept_id: not int")
|
||||
return json_error(404, "invalid dept_id: integer expected")
|
||||
formsemestres = formsemestres.filter_by(dept_id=dept_id)
|
||||
if etape_apo is not None:
|
||||
formsemestres = formsemestres.join(FormSemestreEtape).filter(
|
||||
FormSemestreEtape.etape_apo == etape_apo
|
||||
)
|
||||
inscr_joined = False
|
||||
if nip is not None:
|
||||
formsemestres = (
|
||||
formsemestres.join(FormSemestreInscription)
|
||||
.join(Identite)
|
||||
.filter_by(code_nip=nip)
|
||||
)
|
||||
inscr_joined = True
|
||||
if ine is not None:
|
||||
if not inscr_joined:
|
||||
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
|
||||
formsemestres = formsemestres.filter_by(code_ine=ine)
|
||||
|
||||
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
|
||||
|
||||
|
||||
@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")
|
||||
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def bulletins(formsemestre_id: int):
|
||||
def bulletins(formsemestre_id: int, version: str = "long"):
|
||||
"""
|
||||
Retourne les bulletins d'un formsemestre donné
|
||||
|
||||
|
@ -145,12 +165,16 @@ def bulletins(formsemestre_id: int):
|
|||
query = FormSemestre.query.filter_by(id=formsemestre_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
|
||||
formsemestre: FormSemestre = query.first()
|
||||
if formsemestre is None:
|
||||
return json_error(404, "formsemestre non trouve")
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
|
||||
data = []
|
||||
for etu in formsemestre.etuds:
|
||||
bul_etu = get_formsemestre_bulletin_etud_json(formsemestre, etu)
|
||||
bul_etu = get_formsemestre_bulletin_etud_json(
|
||||
formsemestre, etu, version=version
|
||||
)
|
||||
data.append(bul_etu.json)
|
||||
|
||||
return jsonify(data)
|
||||
|
@ -381,7 +405,7 @@ def etat_evals(formsemestre_id: int):
|
|||
for evaluation_id in modimpl_results.evaluations_etat:
|
||||
eval_etat = modimpl_results.evaluations_etat[evaluation_id]
|
||||
evaluation = Evaluation.query.get_or_404(evaluation_id)
|
||||
eval_dict = evaluation.to_dict()
|
||||
eval_dict = evaluation.to_dict_api()
|
||||
eval_dict["etat"] = eval_etat.to_dict()
|
||||
|
||||
eval_dict["nb_inscrits"] = modimpl_results.nb_inscrits_module
|
||||
|
@ -444,7 +468,7 @@ def formsemestre_resultat(formsemestre_id: int):
|
|||
"""
|
||||
format_spec = request.args.get("format", None)
|
||||
if format_spec is not None and format_spec != "raw":
|
||||
return json_error(404, "invalid format specification")
|
||||
return json_error(API_CLIENT_ERROR, "invalid format specification")
|
||||
convert_values = format_spec != "raw"
|
||||
|
||||
query = FormSemestre.query.filter_by(id=formsemestre_id)
|
||||
|
@ -453,16 +477,14 @@ 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)
|
||||
rows, footer_rows, titles, column_ids = res.get_table_recap(
|
||||
convert_values=convert_values,
|
||||
include_evaluations=False,
|
||||
mode_jury=False,
|
||||
allow_html=False,
|
||||
table = TableRecap(
|
||||
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
|
||||
)
|
||||
# Supprime les champs inutiles (mise en forme)
|
||||
table = [{k: row[k] for k in row if not k[0] == "_"} for row in rows]
|
||||
# Ajoute les groupes
|
||||
rows = table.to_list()
|
||||
# Ajoute le groupe de chaque partition:
|
||||
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
|
||||
for row in table:
|
||||
for row in rows:
|
||||
row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
return jsonify(table)
|
||||
|
||||
return jsonify(rows)
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
ScoDoc 9 API : jury
|
||||
ScoDoc 9 API : jury WIP
|
||||
"""
|
||||
|
||||
from flask import g, jsonify, request
|
||||
from flask import jsonify
|
||||
from flask_login import login_required
|
||||
|
||||
import app
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.scodoc.sco_exceptions import ScoException
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.but import jury_but_recap
|
||||
from app.models import FormSemestre, FormSemestreInscription, Identite
|
||||
from app.but import jury_but_results
|
||||
from app.models import FormSemestre
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
||||
|
||||
|
@ -33,7 +31,7 @@ def decisions_jury(formsemestre_id: int):
|
|||
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
|
||||
if formsemestre.formation.is_apc():
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
rows = jury_but_recap.get_jury_but_results(formsemestre)
|
||||
rows = jury_but_results.get_jury_but_results(formsemestre)
|
||||
return jsonify(rows)
|
||||
else:
|
||||
raise ScoException("non implemente")
|
||||
|
|
|
@ -0,0 +1,591 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Assiduités
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
import app.scodoc.sco_assiduites as scass
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import db
|
||||
from app.api import api_bp as bp
|
||||
from app.api import api_web_bp
|
||||
from app.api import get_model_api_object
|
||||
from app.decorators import permission_required, scodoc
|
||||
from app.models import Identite, Justificatif
|
||||
from app.models.assiduites import compute_assiduites_justified
|
||||
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.sco_utils import json_error
|
||||
|
||||
|
||||
# Partie Modèle
|
||||
@bp.route("/justificatif/<int:justif_id>")
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>")
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def justificatif(justif_id: int = None):
|
||||
"""Retourne un objet justificatif à partir de son id
|
||||
|
||||
Exemple de résultat:
|
||||
{
|
||||
"justif_id": 1,
|
||||
"etudid": 2,
|
||||
"date_debut": "2022-10-31T08:00+01:00",
|
||||
"date_fin": "2022-10-31T10:00+01:00",
|
||||
"etat": "valide",
|
||||
"fichier": "archive_id",
|
||||
"raison": "une raison",
|
||||
"entry_date": "2022-10-31T08:00+01:00",
|
||||
"user_id": 1 or null,
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
return get_model_api_object(Justificatif, justif_id, Identite)
|
||||
|
||||
|
||||
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
|
||||
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
|
||||
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
|
||||
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def justificatifs(etudid: int = None, with_query: bool = False):
|
||||
"""
|
||||
Retourne toutes les assiduités d'un étudiant
|
||||
chemin : /justificatifs/<int:etudid>
|
||||
|
||||
Un filtrage peut être donné avec une query
|
||||
chemin : /justificatifs/<int:etudid>/query?
|
||||
|
||||
Les différents filtres :
|
||||
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
|
||||
query?etat=[- liste des états séparé par une virgule -]
|
||||
ex: .../query?etat=validé,modifié
|
||||
Date debut
|
||||
(date de début du justificatif, sont affichés les justificatifs
|
||||
dont la date de début est supérieur ou égale à la valeur donnée):
|
||||
query?date_debut=[- date au format iso -]
|
||||
ex: query?date_debut=2022-11-03T08:00+01:00
|
||||
Date fin
|
||||
(date de fin du justificatif, sont affichés les justificatifs
|
||||
dont la date de fin est inférieure ou égale à la valeur donnée):
|
||||
query?date_fin=[- date au format iso -]
|
||||
ex: query?date_fin=2022-11-03T10:00+01:00
|
||||
user_id (l'id de l'auteur du justificatif)
|
||||
query?user_id=[int]
|
||||
ex query?user_id=3
|
||||
"""
|
||||
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
etud: Identite = query.first_or_404(etudid)
|
||||
justificatifs_query = etud.justificatifs
|
||||
|
||||
if with_query:
|
||||
justificatifs_query = _filter_manager(request, justificatifs_query)
|
||||
|
||||
data_set: list[dict] = []
|
||||
for just in justificatifs_query.all():
|
||||
data = just.to_dict(format_api=True)
|
||||
data_set.append(data)
|
||||
|
||||
return jsonify(data_set)
|
||||
|
||||
|
||||
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_create(etudid: int = None):
|
||||
"""
|
||||
Création d'un justificatif pour l'étudiant (etudid)
|
||||
La requête doit avoir un content type "application/json":
|
||||
[
|
||||
{
|
||||
"date_debut": str,
|
||||
"date_fin": str,
|
||||
"etat": str,
|
||||
},
|
||||
{
|
||||
"date_debut": str,
|
||||
"date_fin": str,
|
||||
"etat": str,
|
||||
"raison":str,
|
||||
}
|
||||
...
|
||||
]
|
||||
|
||||
"""
|
||||
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
|
||||
|
||||
create_list: list[object] = request.get_json(force=True)
|
||||
|
||||
if not isinstance(create_list, list):
|
||||
return json_error(404, "Le contenu envoyé n'est pas une liste")
|
||||
|
||||
errors: dict[int, str] = {}
|
||||
success: dict[int, object] = {}
|
||||
for i, data in enumerate(create_list):
|
||||
code, obj = _create_singular(data, etud)
|
||||
if code == 404:
|
||||
errors[i] = obj
|
||||
else:
|
||||
success[i] = obj
|
||||
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
|
||||
return jsonify({"errors": errors, "success": success})
|
||||
|
||||
|
||||
def _create_singular(
|
||||
data: dict,
|
||||
etud: Identite,
|
||||
) -> tuple[int, object]:
|
||||
errors: list[str] = []
|
||||
|
||||
# -- vérifications de l'objet json --
|
||||
# cas 1 : ETAT
|
||||
etat = data.get("etat", None)
|
||||
if etat is None:
|
||||
errors.append("param 'etat': manquant")
|
||||
elif not scu.EtatJustificatif.contains(etat):
|
||||
errors.append("param 'etat': invalide")
|
||||
|
||||
etat = scu.EtatJustificatif.get(etat)
|
||||
|
||||
# cas 2 : date_debut
|
||||
date_debut = data.get("date_debut", None)
|
||||
if date_debut is None:
|
||||
errors.append("param 'date_debut': manquant")
|
||||
deb = scu.is_iso_formated(date_debut, convert=True)
|
||||
if deb is None:
|
||||
errors.append("param 'date_debut': format invalide")
|
||||
|
||||
# cas 3 : date_fin
|
||||
date_fin = data.get("date_fin", None)
|
||||
if date_fin is None:
|
||||
errors.append("param 'date_fin': manquant")
|
||||
fin = scu.is_iso_formated(date_fin, convert=True)
|
||||
if fin is None:
|
||||
errors.append("param 'date_fin': format invalide")
|
||||
|
||||
# cas 4 : raison
|
||||
|
||||
raison: str = data.get("raison", None)
|
||||
|
||||
if errors:
|
||||
err: str = ", ".join(errors)
|
||||
return (404, err)
|
||||
|
||||
# TOUT EST OK
|
||||
|
||||
try:
|
||||
nouv_justificatif: Justificatif = Justificatif.create_justificatif(
|
||||
date_debut=deb,
|
||||
date_fin=fin,
|
||||
etat=etat,
|
||||
etud=etud,
|
||||
raison=raison,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
db.session.add(nouv_justificatif)
|
||||
db.session.commit()
|
||||
|
||||
return (
|
||||
200,
|
||||
{
|
||||
"justif_id": nouv_justificatif.id,
|
||||
"couverture": scass.justifies(nouv_justificatif),
|
||||
},
|
||||
)
|
||||
except ScoValueError as excp:
|
||||
return (
|
||||
404,
|
||||
excp.args[0],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_edit(justif_id: int):
|
||||
"""
|
||||
Edition d'un justificatif à partir de son id
|
||||
La requête doit avoir un content type "application/json":
|
||||
|
||||
{
|
||||
"etat"?: str,
|
||||
"raison"?: str
|
||||
"date_debut"?: str
|
||||
"date_fin"?: str
|
||||
}
|
||||
"""
|
||||
justificatif_unique: Justificatif = Justificatif.query.filter_by(
|
||||
id=justif_id
|
||||
).first_or_404()
|
||||
|
||||
errors: list[str] = []
|
||||
data = request.get_json(force=True)
|
||||
avant_ids: list[int] = scass.justifies(justificatif_unique)
|
||||
# Vérifications de data
|
||||
|
||||
# Cas 1 : Etat
|
||||
if data.get("etat") is not None:
|
||||
etat = scu.EtatJustificatif.get(data.get("etat"))
|
||||
if etat is None:
|
||||
errors.append("param 'etat': invalide")
|
||||
else:
|
||||
justificatif_unique.etat = etat
|
||||
|
||||
# Cas 2 : raison
|
||||
raison = data.get("raison", False)
|
||||
if raison is not False:
|
||||
justificatif_unique.raison = raison
|
||||
|
||||
deb, fin = None, None
|
||||
|
||||
# cas 3 : date_debut
|
||||
date_debut = data.get("date_debut", False)
|
||||
if date_debut is not False:
|
||||
if date_debut is None:
|
||||
errors.append("param 'date_debut': manquant")
|
||||
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
|
||||
if deb is None:
|
||||
errors.append("param 'date_debut': format invalide")
|
||||
|
||||
if justificatif_unique.date_fin >= deb:
|
||||
errors.append("param 'date_debut': date de début située après date de fin ")
|
||||
|
||||
# cas 4 : date_fin
|
||||
date_fin = data.get("date_fin", False)
|
||||
if date_fin is not False:
|
||||
if date_fin is None:
|
||||
errors.append("param 'date_fin': manquant")
|
||||
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
|
||||
if fin is None:
|
||||
errors.append("param 'date_fin': format invalide")
|
||||
if justificatif_unique.date_debut <= fin:
|
||||
errors.append("param 'date_fin': date de fin située avant date de début ")
|
||||
|
||||
# Mise à jour des dates
|
||||
deb = deb if deb is not None else justificatif_unique.date_debut
|
||||
fin = fin if fin is not None else justificatif_unique.date_fin
|
||||
|
||||
justificatif_unique.date_debut = deb
|
||||
justificatif_unique.date_fin = fin
|
||||
|
||||
if errors:
|
||||
err: str = ", ".join(errors)
|
||||
return json_error(404, err)
|
||||
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"couverture": {
|
||||
"avant": avant_ids,
|
||||
"après": compute_assiduites_justified(
|
||||
Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
|
||||
True,
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/justificatif/delete", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/delete", methods=["POST"])
|
||||
@login_required
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_delete():
|
||||
"""
|
||||
Suppression d'un justificatif à partir de son id
|
||||
|
||||
Forme des données envoyées :
|
||||
|
||||
[
|
||||
<justif_id:int>,
|
||||
...
|
||||
]
|
||||
|
||||
|
||||
"""
|
||||
justificatifs_list: list[int] = request.get_json(force=True)
|
||||
if not isinstance(justificatifs_list, list):
|
||||
return json_error(404, "Le contenu envoyé n'est pas une liste")
|
||||
|
||||
output = {"errors": {}, "success": {}}
|
||||
|
||||
for i, ass in enumerate(justificatifs_list):
|
||||
code, msg = _delete_singular(ass, db)
|
||||
if code == 404:
|
||||
output["errors"][f"{i}"] = msg
|
||||
else:
|
||||
output["success"][f"{i}"] = {"OK": True}
|
||||
db.session.commit()
|
||||
return jsonify(output)
|
||||
|
||||
|
||||
def _delete_singular(justif_id: int, database):
|
||||
justificatif_unique: Justificatif = Justificatif.query.filter_by(
|
||||
id=justif_id
|
||||
).first()
|
||||
if justificatif_unique is None:
|
||||
return (404, "Justificatif non existant")
|
||||
|
||||
archive_name: str = justificatif_unique.fichier
|
||||
|
||||
if archive_name is not None:
|
||||
archiver: JustificatifArchiver = JustificatifArchiver()
|
||||
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
|
||||
|
||||
database.session.delete(justificatif_unique)
|
||||
return (200, "OK")
|
||||
|
||||
|
||||
# Partie archivage
|
||||
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_import(justif_id: int = None):
|
||||
"""
|
||||
Importation d'un fichier (création d'archive)
|
||||
"""
|
||||
if len(request.files) == 0:
|
||||
return json_error(404, "Il n'y a pas de fichier joint")
|
||||
|
||||
file = list(request.files.values())[0]
|
||||
if file.filename == "":
|
||||
return json_error(404, "Il n'y a pas de fichier joint")
|
||||
|
||||
query = Justificatif.query.filter_by(id=justif_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
justificatif_unique: Justificatif = query.first_or_404()
|
||||
|
||||
archive_name: str = justificatif_unique.fichier
|
||||
|
||||
archiver: JustificatifArchiver = JustificatifArchiver()
|
||||
try:
|
||||
fname: str
|
||||
archive_name, fname = archiver.save_justificatif(
|
||||
etudid=justificatif_unique.etudid,
|
||||
filename=file.filename,
|
||||
data=file.stream.read(),
|
||||
archive_name=archive_name,
|
||||
)
|
||||
|
||||
justificatif_unique.fichier = archive_name
|
||||
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"filename": fname})
|
||||
except ScoValueError as err:
|
||||
return json_error(404, err.args[0])
|
||||
|
||||
|
||||
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_export(justif_id: int = None, filename: str = None):
|
||||
"""
|
||||
Retourne un fichier d'une archive d'un justificatif
|
||||
"""
|
||||
|
||||
query = Justificatif.query.filter_by(id=justif_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
justificatif_unique: Justificatif = query.first_or_404()
|
||||
|
||||
archive_name: str = justificatif_unique.fichier
|
||||
if archive_name is None:
|
||||
return json_error(404, "le justificatif ne possède pas de fichier")
|
||||
|
||||
archiver: JustificatifArchiver = JustificatifArchiver()
|
||||
|
||||
try:
|
||||
return archiver.get_justificatif_file(
|
||||
archive_name, justificatif_unique.etudid, filename
|
||||
)
|
||||
except ScoValueError as err:
|
||||
return json_error(404, err.args[0])
|
||||
|
||||
|
||||
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_remove(justif_id: int = None):
|
||||
"""
|
||||
Supression d'un fichier ou d'une archive
|
||||
# TOTALK: Doc, expliquer les noms coté server
|
||||
{
|
||||
"remove": <"all"/"list">
|
||||
|
||||
"filenames"?: [
|
||||
<filename:str>,
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
data: dict = request.get_json(force=True)
|
||||
|
||||
query = Justificatif.query.filter_by(id=justif_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
justificatif_unique: Justificatif = query.first_or_404()
|
||||
|
||||
archive_name: str = justificatif_unique.fichier
|
||||
if archive_name is None:
|
||||
return json_error(404, "le justificatif ne possède pas de fichier")
|
||||
|
||||
remove: str = data.get("remove")
|
||||
if remove is None or remove not in ("all", "list"):
|
||||
return json_error(404, "param 'remove': Valeur invalide")
|
||||
archiver: JustificatifArchiver = JustificatifArchiver()
|
||||
etudid: int = justificatif_unique.etudid
|
||||
try:
|
||||
if remove == "all":
|
||||
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
|
||||
justificatif_unique.fichier = None
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
else:
|
||||
for fname in data.get("filenames", []):
|
||||
archiver.delete_justificatif(
|
||||
etudid=etudid,
|
||||
archive_name=archive_name,
|
||||
filename=fname,
|
||||
)
|
||||
|
||||
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
|
||||
archiver.delete_justificatif(etudid, archive_name)
|
||||
justificatif_unique.fichier = None
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
except ScoValueError as err:
|
||||
return json_error(404, err.args[0])
|
||||
|
||||
return jsonify({"response": "removed"})
|
||||
|
||||
|
||||
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_list(justif_id: int = None):
|
||||
"""
|
||||
Liste les fichiers du justificatif
|
||||
"""
|
||||
|
||||
query = Justificatif.query.filter_by(id=justif_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
justificatif_unique: Justificatif = query.first_or_404()
|
||||
|
||||
archive_name: str = justificatif_unique.fichier
|
||||
|
||||
filenames: list[str] = []
|
||||
|
||||
archiver: JustificatifArchiver = JustificatifArchiver()
|
||||
if archive_name is not None:
|
||||
filenames = archiver.list_justificatifs(
|
||||
archive_name, justificatif_unique.etudid
|
||||
)
|
||||
|
||||
return jsonify(filenames)
|
||||
|
||||
|
||||
# Partie justification
|
||||
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
|
||||
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
|
||||
@scodoc
|
||||
@login_required
|
||||
@permission_required(Permission.ScoView)
|
||||
# @permission_required(Permission.ScoAssiduiteChange)
|
||||
def justif_justifies(justif_id: int = None):
|
||||
"""
|
||||
Liste assiduite_id justifiées par le justificatif
|
||||
"""
|
||||
|
||||
query = Justificatif.query.filter_by(id=justif_id)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
|
||||
|
||||
justificatif_unique: Justificatif = query.first_or_404()
|
||||
|
||||
assiduites_list: list[int] = scass.justifies(justificatif_unique)
|
||||
|
||||
return jsonify(assiduites_list)
|
||||
|
||||
|
||||
# -- Utils --
|
||||
|
||||
|
||||
def _filter_manager(requested, justificatifs_query):
|
||||
"""
|
||||
Retourne les justificatifs entrés filtrés en fonction de la request
|
||||
"""
|
||||
# cas 1 : etat justificatif
|
||||
etat = requested.args.get("etat")
|
||||
if etat is not None:
|
||||
justificatifs_query = scass.filter_justificatifs_by_etat(
|
||||
justificatifs_query, etat
|
||||
)
|
||||
|
||||
# cas 2 : date de début
|
||||
deb = requested.args.get("date_debut", "").replace(" ", "+")
|
||||
deb: datetime = scu.is_iso_formated(deb, True)
|
||||
|
||||
# cas 3 : date de fin
|
||||
fin = requested.args.get("date_fin", "").replace(" ", "+")
|
||||
fin = scu.is_iso_formated(fin, True)
|
||||
|
||||
if (deb, fin) != (None, None):
|
||||
justificatifs_query: Justificatif = scass.filter_by_date(
|
||||
justificatifs_query, Justificatif, deb, fin
|
||||
)
|
||||
|
||||
user_id = requested.args.get("user_id", False)
|
||||
if user_id is not False:
|
||||
justificatif_query: Justificatif = scass.filter_by_user_id(
|
||||
justificatif_query, user_id
|
||||
)
|
||||
|
||||
return justificatifs_query
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -48,6 +48,7 @@ from app.scodoc.sco_permissions import Permission
|
|||
@scodoc
|
||||
@permission_required(Permission.ScoSuperAdmin)
|
||||
def api_get_glob_logos():
|
||||
"""Liste tous les logos"""
|
||||
logos = list_logos()[None]
|
||||
return jsonify(list(logos.keys()))
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -12,13 +12,14 @@ from flask_login import login_required
|
|||
|
||||
import app
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
from app.decorators import scodoc, permission_required
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.models import FormSemestre, FormSemestreInscription, Identite
|
||||
from app.models import GroupDescr, Partition
|
||||
from app.models import GroupDescr, Partition, Scolog
|
||||
from app.models.groups import group_membership
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import sco_groups
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
@ -137,7 +138,7 @@ def etud_in_group_query(group_id: int):
|
|||
"""Étudiants du groupe, filtrés par état"""
|
||||
etat = request.args.get("etat")
|
||||
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
|
||||
return json_error(404, "etat: valeur invalide")
|
||||
return json_error(API_CLIENT_ERROR, "etat: valeur invalide")
|
||||
query = GroupDescr.query.filter_by(id=group_id)
|
||||
if g.scodoc_dept:
|
||||
query = (
|
||||
|
@ -170,24 +171,15 @@ def set_etud_group(etudid: int, group_id: int):
|
|||
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
group = query.first_or_404()
|
||||
if not group.partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
|
||||
return json_error(404, "etud non inscrit au formsemestre du groupe")
|
||||
groups = (
|
||||
GroupDescr.query.filter_by(partition_id=group.partition.id)
|
||||
.join(group_membership)
|
||||
.filter_by(etudid=etudid)
|
||||
|
||||
sco_groups.change_etud_group_in_partition(
|
||||
etudid, group_id, group.partition.to_dict()
|
||||
)
|
||||
ok = False
|
||||
for other_group in groups:
|
||||
if other_group.id == group_id:
|
||||
ok = True
|
||||
else:
|
||||
other_group.etuds.remove(etud)
|
||||
if not ok:
|
||||
group.etuds.append(etud)
|
||||
log(f"set_etud_group({etud}, {group})")
|
||||
db.session.commit()
|
||||
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
|
||||
|
||||
return jsonify({"group_id": group_id, "etudid": etudid})
|
||||
|
||||
|
||||
|
@ -207,9 +199,19 @@ def group_remove_etud(group_id: int, etudid: int):
|
|||
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
group = query.first_or_404()
|
||||
if not group.partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if etud in group.etuds:
|
||||
group.etuds.remove(etud)
|
||||
db.session.commit()
|
||||
Scolog.logdb(
|
||||
method="group_remove_etud",
|
||||
etudid=etud.id,
|
||||
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
|
||||
commit=True,
|
||||
)
|
||||
# Update parcours
|
||||
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
|
||||
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
|
||||
return jsonify({"group_id": group_id, "etudid": etudid})
|
||||
|
||||
|
@ -232,6 +234,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
partition = query.first_or_404()
|
||||
if not partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
groups = (
|
||||
GroupDescr.query.filter_by(partition_id=partition_id)
|
||||
.join(group_membership)
|
||||
|
@ -239,7 +243,15 @@ def partition_remove_etud(partition_id: int, etudid: int):
|
|||
)
|
||||
for group in groups:
|
||||
group.etuds.remove(etud)
|
||||
Scolog.logdb(
|
||||
method="partition_remove_etud",
|
||||
etudid=etud.id,
|
||||
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
|
||||
commit=True,
|
||||
)
|
||||
db.session.commit()
|
||||
# Update parcours
|
||||
partition.formsemestre.update_inscriptions_parcours_from_groups()
|
||||
app.set_sco_dept(partition.formsemestre.departement.acronym)
|
||||
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
|
||||
return jsonify({"partition_id": partition_id, "etudid": etudid})
|
||||
|
@ -262,14 +274,16 @@ def group_create(partition_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
partition: Partition = query.first_or_404()
|
||||
if not partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if not partition.groups_editable:
|
||||
return json_error(404, "partition non editable")
|
||||
return json_error(403, "partition non editable")
|
||||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
group_name = data.get("group_name")
|
||||
if group_name is None:
|
||||
return json_error(404, "missing group name or invalid data format")
|
||||
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
|
||||
if not GroupDescr.check_name(partition, group_name):
|
||||
return json_error(404, "invalid group_name")
|
||||
return json_error(API_CLIENT_ERROR, "invalid group_name")
|
||||
group_name = group_name.strip()
|
||||
|
||||
group = GroupDescr(group_name=group_name, partition_id=partition_id)
|
||||
|
@ -294,8 +308,10 @@ def group_delete(group_id: int):
|
|||
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
group: GroupDescr = query.first_or_404()
|
||||
if not group.partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if not group.partition.groups_editable:
|
||||
return json_error(404, "partition non editable")
|
||||
return json_error(403, "partition non editable")
|
||||
formsemestre_id = group.partition.formsemestre_id
|
||||
log(f"deleting {group}")
|
||||
db.session.delete(group)
|
||||
|
@ -318,14 +334,16 @@ def group_edit(group_id: int):
|
|||
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
group: GroupDescr = query.first_or_404()
|
||||
if not group.partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if not group.partition.groups_editable:
|
||||
return json_error(404, "partition non editable")
|
||||
return json_error(403, "partition non editable")
|
||||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
group_name = data.get("group_name")
|
||||
if group_name is not None:
|
||||
group_name = group_name.strip()
|
||||
if not GroupDescr.check_name(group.partition, group_name, existing=True):
|
||||
return json_error(404, "invalid group_name")
|
||||
return json_error(API_CLIENT_ERROR, "invalid group_name")
|
||||
group.group_name = group_name
|
||||
db.session.add(group)
|
||||
db.session.commit()
|
||||
|
@ -358,17 +376,23 @@ def partition_create(formsemestre_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
|
||||
if not formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
partition_name = data.get("partition_name")
|
||||
if partition_name is None:
|
||||
return json_error(404, "missing partition_name or invalid data format")
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, "missing partition_name or invalid data format"
|
||||
)
|
||||
if partition_name == scu.PARTITION_PARCOURS:
|
||||
return json_error(404, f"invalid partition_name {scu.PARTITION_PARCOURS}")
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, f"invalid partition_name {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
if not Partition.check_name(formsemestre, partition_name):
|
||||
return json_error(404, "invalid partition_name")
|
||||
return json_error(API_CLIENT_ERROR, "invalid partition_name")
|
||||
numero = data.get("numero", 0)
|
||||
if not isinstance(numero, int):
|
||||
return json_error(404, "invalid type for numero")
|
||||
return json_error(API_CLIENT_ERROR, "invalid type for numero")
|
||||
args = {
|
||||
"formsemestre_id": formsemestre_id,
|
||||
"partition_name": partition_name.strip(),
|
||||
|
@ -379,7 +403,7 @@ def partition_create(formsemestre_id: int):
|
|||
boolean_field, False if boolean_field != "groups_editable" else True
|
||||
)
|
||||
if not isinstance(value, bool):
|
||||
return json_error(404, f"invalid type for {boolean_field}")
|
||||
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
|
||||
args[boolean_field] = value
|
||||
|
||||
partition = Partition(**args)
|
||||
|
@ -406,12 +430,14 @@ def formsemestre_order_partitions(formsemestre_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
|
||||
if not formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
|
||||
if not isinstance(partition_ids, int) and not all(
|
||||
isinstance(x, int) for x in partition_ids
|
||||
):
|
||||
return json_error(
|
||||
404,
|
||||
API_CLIENT_ERROR,
|
||||
message="paramètre liste des partitions invalide",
|
||||
)
|
||||
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
|
||||
|
@ -443,12 +469,14 @@ def partition_order_groups(partition_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
partition: Partition = query.first_or_404()
|
||||
if not partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
group_ids = request.get_json(force=True) # may raise 400 Bad Request
|
||||
if not isinstance(group_ids, int) and not all(
|
||||
isinstance(x, int) for x in group_ids
|
||||
):
|
||||
return json_error(
|
||||
404,
|
||||
API_CLIENT_ERROR,
|
||||
message="paramètre liste de groupe invalide",
|
||||
)
|
||||
for group_id, numero in zip(group_ids, range(len(group_ids))):
|
||||
|
@ -484,24 +512,28 @@ def partition_edit(partition_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
partition: Partition = query.first_or_404()
|
||||
if not partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
data = request.get_json(force=True) # may raise 400 Bad Request
|
||||
modified = False
|
||||
partition_name = data.get("partition_name")
|
||||
#
|
||||
if partition_name is not None and partition_name != partition.partition_name:
|
||||
if partition.is_parcours():
|
||||
return json_error(404, f"can't rename {scu.PARTITION_PARCOURS}")
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, f"can't rename {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
if not Partition.check_name(
|
||||
partition.formsemestre, partition_name, existing=True
|
||||
):
|
||||
return json_error(404, "invalid partition_name")
|
||||
return json_error(API_CLIENT_ERROR, "invalid partition_name")
|
||||
partition.partition_name = partition_name.strip()
|
||||
modified = True
|
||||
|
||||
numero = data.get("numero")
|
||||
if numero is not None and numero != partition.numero:
|
||||
if not isinstance(numero, int):
|
||||
return json_error(404, "invalid type for numero")
|
||||
return json_error(API_CLIENT_ERROR, "invalid type for numero")
|
||||
partition.numero = numero
|
||||
modified = True
|
||||
|
||||
|
@ -509,9 +541,11 @@ def partition_edit(partition_id: int):
|
|||
value = data.get(boolean_field)
|
||||
if value is not None and value != getattr(partition, boolean_field):
|
||||
if not isinstance(value, bool):
|
||||
return json_error(404, f"invalid type for {boolean_field}")
|
||||
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
|
||||
if boolean_field == "groups_editable" and partition.is_parcours():
|
||||
return json_error(404, f"can't change {scu.PARTITION_PARCOURS}")
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, f"can't change {scu.PARTITION_PARCOURS}"
|
||||
)
|
||||
setattr(partition, boolean_field, value)
|
||||
modified = True
|
||||
|
||||
|
@ -542,8 +576,12 @@ def partition_delete(partition_id: int):
|
|||
if g.scodoc_dept:
|
||||
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
|
||||
partition: Partition = query.first_or_404()
|
||||
if not partition.formsemestre.etat:
|
||||
return json_error(403, "formsemestre verrouillé")
|
||||
if not partition.partition_name:
|
||||
return json_error(404, "ne peut pas supprimer la partition par défaut")
|
||||
return json_error(
|
||||
API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut"
|
||||
)
|
||||
is_parcours = partition.is_parcours()
|
||||
formsemestre: FormSemestre = partition.formsemestre
|
||||
log(f"deleting partition {partition}")
|
||||
|
|
|
@ -18,6 +18,8 @@ def get_token():
|
|||
@token_auth.login_required
|
||||
def revoke_token():
|
||||
"révoque le jeton de l'utilisateur courant"
|
||||
token_auth.current_user().revoke_token()
|
||||
user = token_auth.current_user()
|
||||
user.revoke_token()
|
||||
db.session.commit()
|
||||
log(f"API: revoking token for {user}")
|
||||
return "", 204
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : outils
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -12,8 +12,8 @@
|
|||
from flask import g, jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app import db
|
||||
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
|
||||
|
@ -187,7 +187,7 @@ def user_password(uid: int):
|
|||
if not password:
|
||||
return json_error(404, "user_password: missing password")
|
||||
if not is_valid_password(password):
|
||||
return json_error(400, "user_password: invalid password")
|
||||
return json_error(API_CLIENT_ERROR, "user_password: invalid password")
|
||||
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
|
||||
if (None not in allowed_depts) and ((user.dept not in allowed_depts)):
|
||||
return json_error(403, "user_password: departement non autorise")
|
||||
|
|
|
@ -6,3 +6,4 @@ from flask import Blueprint
|
|||
bp = Blueprint("auth", __name__)
|
||||
|
||||
from app.auth import routes
|
||||
from app.auth import cas
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
"""
|
||||
auth.cas.py
|
||||
"""
|
||||
import datetime
|
||||
|
||||
import flask
|
||||
from flask import current_app, flash, url_for
|
||||
from flask_login import current_user, login_user
|
||||
|
||||
from app import db
|
||||
from app.auth import bp
|
||||
from app.auth.models import User
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import sco_excel
|
||||
from app.scodoc.sco_exceptions import ScoValueError, AccessDenied
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
# after_cas_login/after_cas_logout : routes appelées par redirect depuis le serveur CAS.
|
||||
|
||||
|
||||
@bp.route("/after_cas_login")
|
||||
def after_cas_login():
|
||||
"Called by CAS after CAS authentication"
|
||||
# Ici on a les infos dans flask.session["CAS_ATTRIBUTES"]
|
||||
if ScoDocSiteConfig.is_cas_enabled() and ("CAS_ATTRIBUTES" in flask.session):
|
||||
# Lookup user:
|
||||
cas_id = flask.session["CAS_ATTRIBUTES"].get(
|
||||
"cas:" + ScoDocSiteConfig.get("cas_attribute_id"),
|
||||
flask.session.get("CAS_USERNAME"),
|
||||
)
|
||||
if cas_id is not None:
|
||||
user: User = User.query.filter_by(cas_id=cas_id).first()
|
||||
if user and user.active:
|
||||
if user.cas_allow_login:
|
||||
current_app.logger.info(f"CAS: login {user.user_name}")
|
||||
if login_user(user):
|
||||
flask.session[
|
||||
"scodoc_cas_login_date"
|
||||
] = datetime.datetime.now().isoformat()
|
||||
user.cas_last_login = datetime.datetime.utcnow()
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return flask.redirect(url_for("scodoc.index"))
|
||||
else:
|
||||
current_app.logger.info(
|
||||
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
|
||||
)
|
||||
else:
|
||||
current_app.logger.info(
|
||||
f"""CAS login denied for {
|
||||
user.user_name if user else ""
|
||||
} cas_id={cas_id} (unknown or inactive)"""
|
||||
)
|
||||
else:
|
||||
current_app.logger.info(
|
||||
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found !
|
||||
(check your ScoDoc config)"""
|
||||
)
|
||||
|
||||
# Echec:
|
||||
flash("échec de l'authentification")
|
||||
return flask.redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/after_cas_logout")
|
||||
def after_cas_logout():
|
||||
"Called by CAS after CAS logout"
|
||||
flash("Vous êtes déconnecté")
|
||||
current_app.logger.info("after_cas_logout")
|
||||
return flask.redirect(url_for("scodoc.index"))
|
||||
|
||||
|
||||
def cas_error_callback(message):
|
||||
"Called by CAS when an error occurs, with a message"
|
||||
raise ScoValueError(f"Erreur authentification CAS: {message}")
|
||||
|
||||
|
||||
def set_cas_configuration(app: flask.app.Flask = None):
|
||||
"""Force la configuration du module flask_cas à partir des paramètres de
|
||||
la config de ScoDoc.
|
||||
Appelé au démarrage et à chaque modif des paramètres.
|
||||
"""
|
||||
app = app or current_app
|
||||
if ScoDocSiteConfig.is_cas_enabled():
|
||||
current_app.logger.debug("CAS: set_cas_configuration")
|
||||
app.config["CAS_SERVER"] = ScoDocSiteConfig.get("cas_server")
|
||||
app.config["CAS_LOGIN_ROUTE"] = ScoDocSiteConfig.get("cas_login_route", "/cas")
|
||||
app.config["CAS_LOGOUT_ROUTE"] = ScoDocSiteConfig.get(
|
||||
"cas_logout_route", "/cas/logout"
|
||||
)
|
||||
app.config["CAS_VALIDATE_ROUTE"] = ScoDocSiteConfig.get(
|
||||
"cas_validate_route", "/cas/serviceValidate"
|
||||
)
|
||||
app.config["CAS_AFTER_LOGIN"] = "auth.after_cas_login"
|
||||
app.config["CAS_AFTER_LOGOUT"] = "auth.after_cas_logout"
|
||||
app.config["CAS_ERROR_CALLBACK"] = cas_error_callback
|
||||
app.config["CAS_SSL_VERIFY"] = ScoDocSiteConfig.get("cas_ssl_verify")
|
||||
app.config["CAS_SSL_CERTIFICATE"] = ScoDocSiteConfig.get("cas_ssl_certificate")
|
||||
else:
|
||||
app.config.pop("CAS_SERVER", None)
|
||||
app.config.pop("CAS_AFTER_LOGIN", None)
|
||||
app.config.pop("CAS_AFTER_LOGOUT", None)
|
||||
app.config.pop("CAS_SSL_VERIFY", None)
|
||||
app.config.pop("CAS_SSL_CERTIFICATE", None)
|
||||
|
||||
|
||||
CAS_USER_INFO_IDS = (
|
||||
"user_name",
|
||||
"nom",
|
||||
"prenom",
|
||||
"email",
|
||||
"roles_string",
|
||||
"active",
|
||||
"dept",
|
||||
"cas_id",
|
||||
"cas_allow_login",
|
||||
"cas_allow_scodoc_login",
|
||||
"email_institutionnel",
|
||||
)
|
||||
CAS_USER_INFO_COMMENTS = (
|
||||
"""user_name:
|
||||
L'identifiant (login).
|
||||
""",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Pour info: 0 si compte inactif",
|
||||
"""Pour info: roles:
|
||||
chaînes séparées par _:
|
||||
1. Le rôle (Ens, Secr ou Admin)
|
||||
2. Le département (en majuscule)
|
||||
""",
|
||||
"""dept:
|
||||
Le département d'appartenance de l'utilisateur. Vide si l'utilisateur
|
||||
intervient dans plusieurs départements.
|
||||
""",
|
||||
"""cas_id:
|
||||
identifiant de l'utilisateur sur CAS (requis pour CAS).
|
||||
""",
|
||||
"""cas_allow_login:
|
||||
autorise la connexion via CAS (optionnel, faux par défaut)
|
||||
""",
|
||||
"""cas_allow_scodoc_login
|
||||
autorise connexion via ScoDoc même si CAS obligatoire (optionnel, faux par défaut)
|
||||
""",
|
||||
"""email_institutionnel
|
||||
optionnel, le mail officiel de l'utilisateur.
|
||||
Maximum 120 caractères.""",
|
||||
)
|
||||
|
||||
|
||||
def cas_users_generate_excel_sample() -> bytes:
|
||||
"""generate an excel document suitable to import users CAS information"""
|
||||
style = sco_excel.excel_make_style(bold=True)
|
||||
titles = CAS_USER_INFO_IDS
|
||||
titles_styles = [style] * len(titles)
|
||||
# Extrait tous les utilisateurs (tous dept et statuts)
|
||||
rows = []
|
||||
for user in User.query.order_by(User.user_name):
|
||||
u_dict = user.to_dict()
|
||||
rows.append([u_dict.get(k) for k in CAS_USER_INFO_IDS])
|
||||
return sco_excel.excel_simple_table(
|
||||
lines=rows,
|
||||
titles=titles,
|
||||
titles_styles=titles_styles,
|
||||
sheet_name="Utilisateurs ScoDoc",
|
||||
comments=CAS_USER_INFO_COMMENTS,
|
||||
)
|
||||
|
||||
|
||||
def cas_users_import_excel_file(datafile) -> int:
|
||||
"""
|
||||
Import users CAS configuration from Excel file.
|
||||
May change cas_id, cas_allow_login, cas_allow_scodoc_login
|
||||
and active.
|
||||
:param datafile: stream to be imported
|
||||
:return: nb de comptes utilisateurs modifiés
|
||||
"""
|
||||
from app.scodoc import sco_import_users
|
||||
|
||||
if not current_user.is_administrator():
|
||||
raise AccessDenied(f"invalid user ({current_user}) must be SuperAdmin")
|
||||
current_app.logger.info("cas_users_import_excel_file by {current_user}")
|
||||
|
||||
users_infos = sco_import_users.read_users_excel_file(
|
||||
datafile, titles=CAS_USER_INFO_IDS
|
||||
)
|
||||
|
||||
return cas_users_import_data(users_infos=users_infos)
|
||||
|
||||
|
||||
def cas_users_import_data(users_infos: list[dict]) -> int:
|
||||
"""Import informations configuration CAS
|
||||
users est une liste de dict, on utilise seulement les champs:
|
||||
- user_name : la clé, l'utilisateur DOIT déjà exister
|
||||
- cas_id : l'ID CAS a enregistrer.
|
||||
- cas_allow_login
|
||||
- cas_allow_scodoc_login
|
||||
Les éventuels autres champs sont ignorés.
|
||||
|
||||
Return: nb de comptes modifiés.
|
||||
"""
|
||||
nb_modif = 0
|
||||
users = []
|
||||
for info in users_infos:
|
||||
user: User = User.query.filter_by(user_name=info["user_name"]).first()
|
||||
if not user:
|
||||
db.session.rollback() # au cas où auto-flush
|
||||
raise ScoValueError(f"""Utilisateur '{info["user_name"]}' inexistant""")
|
||||
modif = False
|
||||
new_cas_id = info["cas_id"].strip()
|
||||
if new_cas_id != (user.cas_id or ""):
|
||||
# check unicity
|
||||
other = User.query.filter_by(cas_id=new_cas_id).first()
|
||||
if other and other.id != user.id:
|
||||
db.session.rollback() # au cas où auto-flush
|
||||
raise ScoValueError(f"cas_id {new_cas_id} dupliqué")
|
||||
user.cas_id = info["cas_id"].strip() or None
|
||||
modif = True
|
||||
val = scu.to_bool(info["cas_allow_login"])
|
||||
if val != user.cas_allow_login:
|
||||
user.cas_allow_login = val
|
||||
modif = True
|
||||
val = scu.to_bool(info["cas_allow_scodoc_login"])
|
||||
if val != user.cas_allow_scodoc_login:
|
||||
user.cas_allow_scodoc_login = val
|
||||
modif = True
|
||||
val = scu.to_bool(info["active"])
|
||||
if val != (user.active or False):
|
||||
user.active = val
|
||||
modif = True
|
||||
if modif:
|
||||
nb_modif += 1
|
||||
# Record modifications
|
||||
for user in users:
|
||||
try:
|
||||
db.session.add(user)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
raise ScoValueError(
|
||||
"Erreur (1) durant l'importation des modifications"
|
||||
) from exc
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
raise ScoValueError(
|
||||
"Erreur (2) durant l'importation des modifications"
|
||||
) from exc
|
||||
return nb_modif
|
|
@ -1,15 +1,20 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
from flask import render_template, current_app
|
||||
from flask_babel import _
|
||||
from app.email import send_email
|
||||
|
||||
from flask import render_template
|
||||
from app.auth.models import User
|
||||
from app.email import get_from_addr, send_email
|
||||
|
||||
|
||||
def send_password_reset_email(user):
|
||||
def send_password_reset_email(user: User):
|
||||
"""Send message allowing to reset password"""
|
||||
recipients = user.get_emails()
|
||||
if not recipients:
|
||||
return
|
||||
token = user.get_reset_password_token()
|
||||
send_email(
|
||||
"[ScoDoc] Réinitialisation de votre mot de passe",
|
||||
sender=current_app.config["SCODOC_MAIL_FROM"],
|
||||
recipients=[user.email],
|
||||
sender=get_from_addr(),
|
||||
recipients=recipients,
|
||||
text_body=render_template("email/reset_password.txt", user=user, token=token),
|
||||
html_body=render_template("email/reset_password.html", user=user, token=token),
|
||||
html_body=render_template("email/reset_password.j2", user=user, token=token),
|
||||
)
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
|
||||
"""Formulaires authentification
|
||||
|
||||
TODO: à revoir complètement pour reprendre ZScoUsers et les pages d'authentification
|
||||
"""
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from flask import request, url_for, redirect
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, HiddenField, PasswordField, StringField, SubmitField
|
||||
from wtforms.fields.simple import FileField
|
||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
|
||||
from app.auth.models import User, is_valid_password
|
||||
|
||||
|
@ -98,3 +97,12 @@ class ResetPasswordForm(FlaskForm):
|
|||
class DeactivateUserForm(FlaskForm):
|
||||
submit = SubmitField("Modifier l'utilisateur")
|
||||
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
class CASUsersImportConfigForm(FlaskForm):
|
||||
user_config_file = FileField(
|
||||
label="Fichier Excel à réimporter",
|
||||
description="""fichier avec les paramètres CAS renseignés""",
|
||||
)
|
||||
submit = SubmitField("Importer le fichier utilisateurs")
|
||||
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})
|
||||
|
|
|
@ -5,12 +5,14 @@
|
|||
import http
|
||||
|
||||
import flask
|
||||
from flask import g, redirect, request, url_for
|
||||
from flask import current_app, g, redirect, request, url_for
|
||||
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
|
||||
import flask_login
|
||||
|
||||
from app import login
|
||||
from app.scodoc.sco_utils import json_error
|
||||
from app.auth.models import User
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc.sco_utils import json_error
|
||||
|
||||
basic_auth = HTTPBasicAuth()
|
||||
token_auth = HTTPTokenAuth()
|
||||
|
@ -83,3 +85,15 @@ def unauthorized():
|
|||
if request.blueprint == "api" or request.blueprint == "apiweb":
|
||||
return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
def logout() -> flask.Response:
|
||||
"""Logout the current user: If CAS session, logout from CAS. Redirect."""
|
||||
if flask_login.current_user:
|
||||
user_name = getattr(flask_login.current_user, "user_name", "anonymous")
|
||||
current_app.logger.info(f"logout user {user_name}")
|
||||
flask_login.logout_user()
|
||||
if ScoDocSiteConfig.is_cas_enabled() and flask.session.get("scodoc_cas_login_date"):
|
||||
flask.session.pop("scodoc_cas_login_date", None)
|
||||
return redirect(url_for("cas.logout"))
|
||||
return redirect(url_for("scodoc.index"))
|
||||
|
|
|
@ -19,9 +19,10 @@ from werkzeug.security import generate_password_hash, check_password_hash
|
|||
|
||||
import jwt
|
||||
|
||||
from app import db, log, login
|
||||
from app import db, email, log, login
|
||||
from app.models import Departement
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
|
||||
|
@ -31,7 +32,7 @@ from app.scodoc import sco_etud # a deplacer dans scu
|
|||
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
|
||||
|
||||
|
||||
def is_valid_password(cleartxt):
|
||||
def is_valid_password(cleartxt) -> bool:
|
||||
"""Check password.
|
||||
returns True if OK.
|
||||
"""
|
||||
|
@ -48,17 +49,45 @@ def is_valid_password(cleartxt):
|
|||
return False
|
||||
|
||||
|
||||
def invalid_user_name(user_name: str) -> bool:
|
||||
"Check that user_name (aka login) is invalid"
|
||||
return (
|
||||
(len(user_name) < 2)
|
||||
or (len(user_name) >= USERNAME_STR_LEN)
|
||||
or not VALID_LOGIN_EXP.match(user_name)
|
||||
)
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""ScoDoc users, handled by Flask / SQLAlchemy"""
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_name = db.Column(db.String(64), index=True, unique=True)
|
||||
user_name = db.Column(db.String(USERNAME_STR_LEN), index=True, unique=True)
|
||||
"le login"
|
||||
email = db.Column(db.String(120))
|
||||
|
||||
nom = db.Column(db.String(64))
|
||||
prenom = db.Column(db.String(64))
|
||||
"email à utiliser par ScoDoc"
|
||||
email_institutionnel = db.Column(db.String(120))
|
||||
"email dans l'établissement, facultatif"
|
||||
nom = db.Column(db.String(USERNAME_STR_LEN))
|
||||
prenom = db.Column(db.String(USERNAME_STR_LEN))
|
||||
dept = db.Column(db.String(SHORT_STR_LEN), index=True)
|
||||
"acronyme du département de l'utilisateur"
|
||||
active = db.Column(db.Boolean, default=True, index=True)
|
||||
"si faux, compte utilisateur désactivé"
|
||||
cas_id = db.Column(db.Text(), index=True, unique=True, nullable=True)
|
||||
"uid sur le CAS (id, mail ou autre attribut, selon config.cas_attribute_id)"
|
||||
cas_allow_login = db.Column(
|
||||
db.Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
"Peut-on se logguer via le CAS ?"
|
||||
cas_allow_scodoc_login = db.Column(
|
||||
db.Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
"""Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ?
|
||||
(le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API)
|
||||
"""
|
||||
cas_last_login = db.Column(db.DateTime, nullable=True)
|
||||
"""date du dernier login via CAS"""
|
||||
|
||||
password_hash = db.Column(db.String(128))
|
||||
password_scodoc7 = db.Column(db.String(42))
|
||||
|
@ -67,6 +96,8 @@ class User(UserMixin, db.Model):
|
|||
date_created = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
date_expiration = db.Column(db.DateTime, default=None)
|
||||
passwd_temp = db.Column(db.Boolean, default=False)
|
||||
"""champ obsolete. Si connexion alors que passwd_temp est vrai,
|
||||
efface mot de passe et redirige vers accueil."""
|
||||
token = db.Column(db.Text(), index=True, unique=True)
|
||||
token_expiration = db.Column(db.DateTime)
|
||||
|
||||
|
@ -86,7 +117,7 @@ class User(UserMixin, db.Model):
|
|||
self.roles = []
|
||||
self.user_roles = []
|
||||
# check login:
|
||||
if kwargs.get("user_name") and not VALID_LOGIN_EXP.match(kwargs["user_name"]):
|
||||
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
|
||||
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
|
||||
super(User, self).__init__(**kwargs)
|
||||
# Ajoute roles:
|
||||
|
@ -103,7 +134,8 @@ class User(UserMixin, db.Model):
|
|||
# current_app.logger.info("creating user with roles={}".format(self.roles))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.user_name} id={self.id} dept={self.dept}{' (inactive)' if not self.active else ''}>"
|
||||
return f"""<User {self.user_name} id={self.id} dept={self.dept}{
|
||||
' (inactive)' if not self.active else ''}>"""
|
||||
|
||||
def __str__(self):
|
||||
return self.user_name
|
||||
|
@ -115,30 +147,56 @@ class User(UserMixin, db.Model):
|
|||
self.password_hash = generate_password_hash(password)
|
||||
else:
|
||||
self.password_hash = None
|
||||
# La création d'un mot de passe efface l'éventuel mot de passe historique
|
||||
self.password_scodoc7 = None
|
||||
self.passwd_temp = False
|
||||
|
||||
def check_password(self, password):
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Check given password vs current one.
|
||||
Returns `True` if the password matched, `False` otherwise.
|
||||
"""
|
||||
if not self.active: # inactived users can't login
|
||||
current_app.logger.warning(
|
||||
f"auth: login attempt from inactive account {self}"
|
||||
)
|
||||
return False
|
||||
if (not self.password_hash) and self.password_scodoc7:
|
||||
# Special case: user freshly migrated from ScoDoc7
|
||||
if scu.check_scodoc7_password(self.password_scodoc7, password):
|
||||
current_app.logger.warning(
|
||||
f"migrating legacy ScoDoc7 password for {self}"
|
||||
)
|
||||
self.set_password(password)
|
||||
self.password_scodoc7 = None
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return True
|
||||
if self.passwd_temp:
|
||||
# Anciens comptes ScoDoc 7 non migrés
|
||||
# désactive le compte par sécurité.
|
||||
current_app.logger.warning(f"auth: desactivating legacy account {self}")
|
||||
self.active = False
|
||||
self.passwd_temp = True
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
send_notif_desactivation_user(self)
|
||||
return False
|
||||
|
||||
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
|
||||
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
|
||||
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
|
||||
return False
|
||||
|
||||
if not self.password_hash: # user without password can't login
|
||||
if self.password_scodoc7:
|
||||
# Special case: user freshly migrated from ScoDoc7
|
||||
return self._migrate_scodoc7_password(password)
|
||||
return False
|
||||
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def _migrate_scodoc7_password(self, password) -> bool:
|
||||
"""After migration, rehash password."""
|
||||
if scu.check_scodoc7_password(self.password_scodoc7, password):
|
||||
current_app.logger.warning(
|
||||
f"auth: migrating legacy ScoDoc7 password for {self}"
|
||||
)
|
||||
self.set_password(password)
|
||||
self.password_scodoc7 = None
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_reset_password_token(self, expires_in=600):
|
||||
"Un token pour réinitialiser son mot de passe"
|
||||
return jwt.encode(
|
||||
|
@ -155,7 +213,7 @@ class User(UserMixin, db.Model):
|
|||
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
|
||||
)
|
||||
except jwt.exceptions.ExpiredSignatureError:
|
||||
log(f"verify_reset_password_token: token expired")
|
||||
log("verify_reset_password_token: token expired")
|
||||
except:
|
||||
return None
|
||||
try:
|
||||
|
@ -184,6 +242,12 @@ class User(UserMixin, db.Model):
|
|||
"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,
|
||||
"status_txt": "actif" if self.active else "fermé",
|
||||
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
|
||||
"nom": (self.nom or ""), # sco8
|
||||
|
@ -200,22 +264,39 @@ class User(UserMixin, db.Model):
|
|||
}
|
||||
if include_email:
|
||||
data["email"] = self.email or ""
|
||||
data["email_institutionnel"] = self.email_institutionnel or ""
|
||||
return data
|
||||
|
||||
def from_dict(self, data, new_user=False):
|
||||
def from_dict(self, data: dict, new_user=False):
|
||||
"""Set users' attributes from given dict values.
|
||||
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
|
||||
"""
|
||||
for field in ["nom", "prenom", "dept", "active", "email", "date_expiration"]:
|
||||
for field in [
|
||||
"nom",
|
||||
"prenom",
|
||||
"dept",
|
||||
"active",
|
||||
"email",
|
||||
"email_institutionnel",
|
||||
"date_expiration",
|
||||
"cas_id",
|
||||
]:
|
||||
if field in data:
|
||||
setattr(self, field, data[field] or None)
|
||||
# required boolean fields
|
||||
for field in [
|
||||
"cas_allow_login",
|
||||
"cas_allow_scodoc_login",
|
||||
]:
|
||||
setattr(self, field, scu.to_bool(data.get(field, False)))
|
||||
|
||||
if new_user:
|
||||
if "user_name" in data:
|
||||
# never change name of existing users
|
||||
self.user_name = data["user_name"]
|
||||
if "password" in data:
|
||||
self.set_password(data["password"])
|
||||
if not VALID_LOGIN_EXP.match(self.user_name):
|
||||
if invalid_user_name(self.user_name):
|
||||
raise ValueError(f"invalid user_name: {self.user_name}")
|
||||
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
|
||||
if "roles_string" in data:
|
||||
|
@ -241,7 +322,7 @@ class User(UserMixin, db.Model):
|
|||
|
||||
@staticmethod
|
||||
def check_token(token):
|
||||
"""Retreive user for given token, chek token's validity
|
||||
"""Retreive user for given token, check token's validity
|
||||
and returns the user object.
|
||||
"""
|
||||
user = User.query.filter_by(token=token).first()
|
||||
|
@ -255,6 +336,15 @@ class User(UserMixin, db.Model):
|
|||
return self._departement.id
|
||||
return None
|
||||
|
||||
def get_emails(self):
|
||||
"List mail adresses to contact this user"
|
||||
mails = []
|
||||
if self.email:
|
||||
mails.append(self.email)
|
||||
if self.email_institutionnel:
|
||||
mails.append(self.email_institutionnel)
|
||||
return mails
|
||||
|
||||
# Permissions management:
|
||||
def has_permission(self, perm: int, dept=False):
|
||||
"""Check if user has permission `perm` in given `dept`.
|
||||
|
@ -310,7 +400,7 @@ class User(UserMixin, db.Model):
|
|||
"""string repr. of user's roles (with depts)
|
||||
e.g. "Ens_RT, Ens_Info, Secr_CJ"
|
||||
"""
|
||||
return ",".join(
|
||||
return ", ".join(
|
||||
f"{r.role.name or ''}_{r.dept or ''}"
|
||||
for r in self.user_roles
|
||||
if r is not None
|
||||
|
@ -339,24 +429,17 @@ class User(UserMixin, db.Model):
|
|||
"""nomplogin est le nom en majuscules suivi du prénom et du login
|
||||
e.g. Dupont Pierre (dupont)
|
||||
"""
|
||||
if self.nom:
|
||||
n = sco_etud.format_nom(self.nom)
|
||||
else:
|
||||
n = self.user_name.upper()
|
||||
return "%s %s (%s)" % (
|
||||
n,
|
||||
sco_etud.format_prenom(self.prenom),
|
||||
self.user_name,
|
||||
)
|
||||
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
|
||||
return f"{nom} {sco_etud.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)"
|
||||
or None if user does not exist
|
||||
"""
|
||||
m = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
if m:
|
||||
user_name = m.group(1)
|
||||
match = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
if match:
|
||||
user_name = match.group(1)
|
||||
u = User.query.filter_by(user_name=user_name).first()
|
||||
if u:
|
||||
return u.id
|
||||
|
@ -393,6 +476,8 @@ class User(UserMixin, db.Model):
|
|||
|
||||
|
||||
class AnonymousUser(AnonymousUserMixin):
|
||||
"Notre utilisateur anonyme"
|
||||
|
||||
def has_permission(self, perm, dept=None):
|
||||
return False
|
||||
|
||||
|
@ -509,7 +594,7 @@ class UserRole(db.Model):
|
|||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<UserRole u={} r={} dept={}>".format(self.user, self.role, self.dept)
|
||||
return f"<UserRole u={self.user} r={self.role} dept={self.dept}>"
|
||||
|
||||
@staticmethod
|
||||
def role_dept_from_string(role_dept: str):
|
||||
|
@ -517,18 +602,21 @@ class UserRole(db.Model):
|
|||
role_dept, of the forme "Role_Dept".
|
||||
role is a Role instance, dept is a string, or None.
|
||||
"""
|
||||
fields = role_dept.split("_", 1) # maxsplit=1, le dept peut contenir un "_"
|
||||
fields = role_dept.strip().split("_", 1)
|
||||
# maxsplit=1, le dept peut contenir un "_"
|
||||
if len(fields) != 2:
|
||||
current_app.logger.warning(
|
||||
f"role_dept_from_string: Invalid role_dept '{role_dept}'"
|
||||
f"auth: role_dept_from_string: Invalid role_dept '{role_dept}'"
|
||||
)
|
||||
raise ScoValueError("Invalid role_dept")
|
||||
role_name, dept = fields
|
||||
dept = dept.strip() if dept else ""
|
||||
if dept == "":
|
||||
dept = None
|
||||
|
||||
role = Role.query.filter_by(name=role_name).first()
|
||||
if role is None:
|
||||
raise ScoValueError("role %s does not exists" % role_name)
|
||||
raise ScoValueError(f"role {role_name} does not exists")
|
||||
return (role, dept)
|
||||
|
||||
|
||||
|
@ -545,3 +633,22 @@ def get_super_admin():
|
|||
)
|
||||
assert admin_user
|
||||
return admin_user
|
||||
|
||||
|
||||
def send_notif_desactivation_user(user: User):
|
||||
"""Envoi un message mail de notification à l'admin et à l'adresse du compte désactivé"""
|
||||
recipients = user.get_emails() + [current_app.config.get("SCODOC_ADMIN_MAIL")]
|
||||
txt = [
|
||||
f"""Le compte ScoDoc '{user.user_name}' associé à votre adresse <{user.email}>""",
|
||||
"""a été désactivé par le système car son mot de passe n'était pas valide.\n""",
|
||||
"""Contactez votre responsable pour le ré-activer.\n""",
|
||||
"""Ceci est un message automatique, ne pas répondre.""",
|
||||
]
|
||||
txt = "\n".join(txt)
|
||||
email.send_email(
|
||||
f"ScoDoc: désactivation automatique du compte {user.user_name}",
|
||||
email.get_from_addr(),
|
||||
recipients,
|
||||
txt,
|
||||
)
|
||||
return txt
|
||||
|
|
|
@ -3,54 +3,88 @@
|
|||
auth.routes.py
|
||||
"""
|
||||
|
||||
import flask
|
||||
from flask import current_app, flash, render_template
|
||||
from flask import redirect, url_for, request
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
from flask_login import login_user, current_user
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.auth import bp
|
||||
from app.auth import bp, cas, logic
|
||||
from app.auth.forms import (
|
||||
CASUsersImportConfigForm,
|
||||
LoginForm,
|
||||
UserCreationForm,
|
||||
ResetPasswordRequestForm,
|
||||
ResetPasswordForm,
|
||||
ResetPasswordRequestForm,
|
||||
UserCreationForm,
|
||||
)
|
||||
from app.auth.models import Role
|
||||
from app.auth.models import User
|
||||
from app.auth.models import Role, User, invalid_user_name
|
||||
from app.auth.email import send_password_reset_email
|
||||
from app.decorators import admin_required
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
_ = lambda x: x # sans babel
|
||||
_l = _
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"ScoDoc Login form"
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("scodoc.index"))
|
||||
def _login_form():
|
||||
"""le formulaire de login, avec un lien CAS s'il est configuré."""
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(user_name=form.user_name.data).first()
|
||||
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
|
||||
if invalid_user_name(form.user_name.data):
|
||||
user = None
|
||||
else:
|
||||
user = User.query.filter_by(user_name=form.user_name.data).first()
|
||||
if user is None or not user.check_password(form.password.data):
|
||||
current_app.logger.info("login: invalid (%s)", form.user_name.data)
|
||||
flash(_("Nom ou mot de passe invalide"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
|
||||
current_app.logger.info("login: success (%s)", form.user_name.data)
|
||||
return form.redirect("scodoc.index")
|
||||
message = request.args.get("message", "")
|
||||
|
||||
return render_template(
|
||||
"auth/login.html", title=_("Sign In"), form=form, message=message
|
||||
"auth/login.j2",
|
||||
title=_("Sign In"),
|
||||
form=form,
|
||||
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""ScoDoc Login form
|
||||
Si paramètre cas_force, redirige vers le CAS.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("scodoc.index"))
|
||||
|
||||
if ScoDocSiteConfig.get("cas_force"):
|
||||
current_app.logger.info("login: forcing CAS")
|
||||
return redirect(url_for("cas.login"))
|
||||
|
||||
return _login_form()
|
||||
|
||||
|
||||
@bp.route("/login_scodoc", methods=["GET", "POST"])
|
||||
def login_scodoc():
|
||||
"""ScoDoc Login form.
|
||||
Formulaire login, sans redirection immédiate sur CAS si ce dernier est configuré.
|
||||
Sans CAS, ce formulaire est identique à /login
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("scodoc.index"))
|
||||
return _login_form()
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"Logout current user and redirect to home page"
|
||||
logout_user()
|
||||
return redirect(url_for("scodoc.index"))
|
||||
def logout() -> flask.Response:
|
||||
"Logout a scodoc user. If CAS session, logout from CAS. Redirect."
|
||||
return logic.logout()
|
||||
|
||||
|
||||
@bp.route("/create_user", methods=["GET", "POST"])
|
||||
|
@ -63,11 +97,9 @@ def create_user():
|
|||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash("User {} created".format(user.user_name))
|
||||
flash(f"Utilisateur {user.user_name} créé")
|
||||
return redirect(url_for("scodoc.index"))
|
||||
return render_template(
|
||||
"auth/register.html", title="Création utilisateur", form=form
|
||||
)
|
||||
return render_template("auth/register.j2", title="Création utilisateur", form=form)
|
||||
|
||||
|
||||
@bp.route("/reset_password_request", methods=["GET", "POST"])
|
||||
|
@ -98,13 +130,16 @@ def reset_password_request():
|
|||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template(
|
||||
"auth/reset_password_request.html", title=_("Reset Password"), form=form
|
||||
"auth/reset_password_request.j2",
|
||||
title=_("Reset Password"),
|
||||
form=form,
|
||||
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/reset_password/<token>", methods=["GET", "POST"])
|
||||
def reset_password(token):
|
||||
"Reset passord après demande par mail"
|
||||
"Reset password après demande par mail"
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("scodoc.index"))
|
||||
user: User = User.verify_reset_password_token(token)
|
||||
|
@ -116,7 +151,7 @@ def reset_password(token):
|
|||
db.session.commit()
|
||||
flash(_("Votre mot de passe a été changé."))
|
||||
return redirect(url_for("auth.login"))
|
||||
return render_template("auth/reset_password.html", form=form, user=user)
|
||||
return render_template("auth/reset_password.j2", form=form, user=user)
|
||||
|
||||
|
||||
@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])
|
||||
|
@ -126,3 +161,34 @@ def reset_standard_roles_permissions():
|
|||
Role.reset_standard_roles_permissions()
|
||||
flash("rôles standards réinitialisés !")
|
||||
return redirect(url_for("scodoc.configuration"))
|
||||
|
||||
|
||||
@bp.route("/cas_users_generate_excel_sample")
|
||||
@admin_required
|
||||
def cas_users_generate_excel_sample():
|
||||
"une feuille excel pour importation config CAS"
|
||||
data = cas.cas_users_generate_excel_sample()
|
||||
return scu.send_file(data, "ImportConfigCAS", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
|
||||
|
||||
|
||||
@bp.route("/cas_users_import_config", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def cas_users_import_config():
|
||||
"""Import utilisateurs depuis feuille Excel"""
|
||||
form = CASUsersImportConfigForm()
|
||||
if form.validate_on_submit():
|
||||
if form.cancel.data: # cancel button
|
||||
return redirect(url_for("scodoc.configuration"))
|
||||
datafile = request.files[form.user_config_file.name]
|
||||
nb_modif = cas.cas_users_import_excel_file(datafile)
|
||||
current_app.logger.info(f"cas_users_import_config: {nb_modif} comptes modifiés")
|
||||
flash(f"Config. CAS de {nb_modif} comptes modifiée.")
|
||||
return redirect(url_for("scodoc.configuration"))
|
||||
|
||||
return render_template(
|
||||
"auth/cas_users_import_config.j2",
|
||||
title=_("Importation configuration CAS utilisateurs"),
|
||||
form=form,
|
||||
)
|
||||
|
||||
return
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -8,15 +8,15 @@
|
|||
Edition associations UE <-> Ref. Compétence
|
||||
"""
|
||||
from flask import g, url_for
|
||||
from app import db, log
|
||||
from app.models import Formation, UniteEns
|
||||
from app.models.but_refcomp import ApcNiveau
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.models import ApcReferentielCompetences, Formation, UniteEns
|
||||
from app.scodoc import codes_cursus
|
||||
|
||||
|
||||
def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
|
||||
"""Form. HTML pour associer une UE à un niveau de compétence"""
|
||||
if ue.type != sco_codes_parcours.UE_STANDARD:
|
||||
def form_ue_choix_niveau(ue: UniteEns) -> str:
|
||||
"""Form. HTML pour associer une UE à un niveau de compétence.
|
||||
Le menu select lui meême est vide et rempli en JS par appel à get_ue_niveaux_options_html
|
||||
"""
|
||||
if ue.type != codes_cursus.UE_STANDARD:
|
||||
return ""
|
||||
ref_comp = ue.formation.referentiel_competence
|
||||
if ref_comp is None:
|
||||
|
@ -27,11 +27,70 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
|
|||
}">associer un référentiel de compétence</a>
|
||||
</div>
|
||||
</div>"""
|
||||
annee = 1 if ue.semestre_idx is None else (ue.semestre_idx + 1) // 2 # 1, 2, 3
|
||||
niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
|
||||
# Les parcours:
|
||||
parcours_options = []
|
||||
for parcour in ref_comp.parcours:
|
||||
parcours_options.append(
|
||||
f"""<option value="{parcour.id}" {
|
||||
'selected' if ue.parcour == parcour else ''}
|
||||
>{parcour.libelle} ({parcour.code})
|
||||
</option>"""
|
||||
)
|
||||
|
||||
newline = "\n"
|
||||
return f"""
|
||||
<div class="ue_choix_niveau">
|
||||
<form class="form_ue_choix_niveau">
|
||||
<div class="cont_ue_choix_niveau">
|
||||
<div>
|
||||
<b>Parcours :</b>
|
||||
<select class="select_parcour"
|
||||
onchange="set_ue_parcour(this);"
|
||||
data-ue_id="{ue.id}"
|
||||
data-setter="{
|
||||
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
|
||||
}">
|
||||
<option value="" {
|
||||
'selected' if ue.parcour is None else ''
|
||||
}>Tous</option>
|
||||
{newline.join(parcours_options)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<b>Niveau de compétence :</b>
|
||||
<select class="select_niveau_ue"
|
||||
onchange="set_ue_niveau_competence(this);"
|
||||
data-ue_id="{ue.id}"
|
||||
data-setter="{
|
||||
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
|
||||
}">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def get_ue_niveaux_options_html(ue: UniteEns) -> str:
|
||||
"""fragment html avec les options du menu de sélection du
|
||||
niveau de compétences associé à une UE.
|
||||
|
||||
Si l'UE n'a pas de parcours associé: présente les niveaux
|
||||
de tous les parcours.
|
||||
Si l'UE a un parcours: seulement les niveaux de ce parcours.
|
||||
"""
|
||||
ref_comp: ApcReferentielCompetences = ue.formation.referentiel_competence
|
||||
if ref_comp is None:
|
||||
return ""
|
||||
# Les niveaux:
|
||||
annee = ue.annee() # 1, 2, 3
|
||||
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
|
||||
annee, parcour=ue.parcour
|
||||
)
|
||||
|
||||
# Les niveaux déjà associés à d'autres UE du même semestre
|
||||
autres_ues = formation.ues.filter_by(semestre_idx=ue.semestre_idx)
|
||||
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
|
||||
niveaux_autres_ues = {
|
||||
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
|
||||
}
|
||||
|
@ -39,18 +98,14 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
|
|||
if niveaux_by_parcours["TC"]: # TC pour Tronc Commun
|
||||
options.append("""<optgroup label="Tronc commun">""")
|
||||
for n in niveaux_by_parcours["TC"]:
|
||||
if n.id in niveaux_autres_ues:
|
||||
disabled = "disabled"
|
||||
else:
|
||||
disabled = ""
|
||||
options.append(
|
||||
f"""<option value="{n.id}" {'selected'
|
||||
if ue.niveau_competence == n else ''}
|
||||
{disabled}>{n.annee} {n.competence.titre_long}
|
||||
f"""<option value="{n.id}" {
|
||||
'selected' if ue.niveau_competence == n else ''}
|
||||
>{n.annee} {n.competence.titre_long}
|
||||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
for parcour in ref_comp.parcours:
|
||||
for parcour in parcours:
|
||||
if len(niveaux_by_parcours[parcour.id]):
|
||||
options.append(f"""<optgroup label="Parcours {parcour.libelle}">""")
|
||||
for n in niveaux_by_parcours[parcour.id]:
|
||||
|
@ -65,46 +120,7 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
|
|||
niveau {n.ordre}</option>"""
|
||||
)
|
||||
options.append("""</optgroup>""")
|
||||
options_str = "\n".join(options)
|
||||
return f"""
|
||||
<div class="ue_choix_niveau">
|
||||
<form class="form_ue_choix_niveau">
|
||||
<b>Niveau de compétence associé:</b>
|
||||
<select onchange="set_ue_niveau_competence(this);"
|
||||
data-ue_id="{ue.id}"
|
||||
data-setter="{
|
||||
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
|
||||
}">
|
||||
<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>
|
||||
{options_str}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def set_ue_niveau_competence(ue_id: int, niveau_id: int):
|
||||
"""Associe le niveau et l'UE"""
|
||||
ue = UniteEns.query.get_or_404(ue_id)
|
||||
|
||||
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
|
||||
niveaux_autres_ues = {
|
||||
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
|
||||
}
|
||||
if niveau_id in niveaux_autres_ues:
|
||||
log(
|
||||
f"set_ue_niveau_competence: denying association of {ue} to already associated {niveau_id}"
|
||||
)
|
||||
return "", 409 # conflict
|
||||
if niveau_id == "":
|
||||
niveau = ""
|
||||
# suppression de l'association
|
||||
ue.niveau_competence = None
|
||||
else:
|
||||
niveau = ApcNiveau.query.get_or_404(niveau_id)
|
||||
ue.niveau_competence = niveau
|
||||
db.session.add(ue)
|
||||
db.session.commit()
|
||||
log(f"set_ue_niveau_competence( {ue}, {niveau} )")
|
||||
|
||||
return "", 204
|
||||
return (
|
||||
f"""<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>"""
|
||||
+ "\n".join(options)
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -19,10 +19,10 @@ from app.models.ues import UniteEns
|
|||
from app.scodoc import sco_bulletins, sco_utils as scu
|
||||
from app.scodoc import sco_bulletins_json
|
||||
from app.scodoc import sco_bulletins_pdf
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_groups
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
|
||||
from app.scodoc.codes_cursus import UE_SPORT, DEF
|
||||
from app.scodoc.sco_utils import fmt_note
|
||||
|
||||
|
||||
|
@ -80,6 +80,9 @@ class BulletinBUT:
|
|||
"""
|
||||
res = self.res
|
||||
|
||||
if (etud.id, ue.id) in self.res.dispense_ues:
|
||||
return {}
|
||||
|
||||
if ue.type == UE_SPORT:
|
||||
modimpls_spo = [
|
||||
modimpl
|
||||
|
@ -154,7 +157,7 @@ class BulletinBUT:
|
|||
for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
|
||||
[etud.id]
|
||||
].iterrows():
|
||||
if sco_codes_parcours.code_ue_validant(ue_capitalisee.code):
|
||||
if codes_cursus.code_ue_validant(ue_capitalisee.code):
|
||||
ue = UniteEns.query.get(ue_capitalisee.ue_id) # XXX cacher ?
|
||||
# déjà capitalisé ? montre la meilleure
|
||||
if ue.acronyme in d:
|
||||
|
@ -184,6 +187,8 @@ class BulletinBUT:
|
|||
)
|
||||
if ue_capitalisee.formsemestre_id
|
||||
else None,
|
||||
"ressources": {}, # sans détail en BUT
|
||||
"saes": {},
|
||||
}
|
||||
if self.prefs["bul_show_ects"]:
|
||||
d[ue.acronyme]["ECTS"] = {
|
||||
|
@ -239,6 +244,7 @@ class BulletinBUT:
|
|||
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"]
|
||||
|
@ -256,10 +262,11 @@ class BulletinBUT:
|
|||
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
|
||||
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
|
||||
try:
|
||||
etud_ues_ids = self.res.etud_ues_ids(etud.id)
|
||||
poids = {
|
||||
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
|
||||
for ue in self.res.ues
|
||||
if ue.type != UE_SPORT
|
||||
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
|
||||
}
|
||||
except KeyError:
|
||||
poids = collections.defaultdict(lambda: 0.0)
|
||||
|
@ -356,7 +363,7 @@ class BulletinBUT:
|
|||
"formsemestre_id": formsemestre.id,
|
||||
"etat_inscription": etat_inscription,
|
||||
"options": sco_preferences.bulletin_option_affichage(
|
||||
formsemestre.id, self.prefs
|
||||
formsemestre, self.prefs
|
||||
),
|
||||
}
|
||||
if not published:
|
||||
|
@ -380,7 +387,7 @@ class BulletinBUT:
|
|||
"injustifie": nbabs - nbabsjust,
|
||||
"total": nbabs,
|
||||
}
|
||||
decisions_ues = self.res.get_etud_decision_ues(etud.id) or {}
|
||||
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
|
||||
if self.prefs["bul_show_ects"]:
|
||||
ects_tot = res.etud_ects_tot_sem(etud.id)
|
||||
ects_acquis = res.get_etud_ects_valides(etud.id, decisions_ues)
|
||||
|
@ -460,6 +467,7 @@ class BulletinBUT:
|
|||
"ressources": {},
|
||||
"saes": {},
|
||||
"ues": {},
|
||||
"ues_capitalisees": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -467,6 +475,7 @@ class BulletinBUT:
|
|||
|
||||
def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
|
||||
"""Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
|
||||
(pas utilisé pour json/html)
|
||||
Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
|
||||
"""
|
||||
d = self.bulletin_etud(
|
||||
|
@ -495,7 +504,7 @@ class BulletinBUT:
|
|||
# --- Decision Jury
|
||||
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
|
||||
etud.id,
|
||||
self.res.formsemestre.id,
|
||||
self.res.formsemestre,
|
||||
format="html",
|
||||
show_date_inscr=self.prefs["bul_show_date_inscr"],
|
||||
show_decisions=self.prefs["bul_show_decision"],
|
||||
|
|
|
@ -1,10 +1,24 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Génération bulletin BUT au format PDF standard
|
||||
|
||||
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_bulletinetud(infos)
|
||||
- instance de BulletinGeneratorStandardBUT(infos)
|
||||
- BulletinGeneratorStandardBUT.generate(format="pdf")
|
||||
sco_bulletins_generator.BulletinGenerator.generate()
|
||||
.generate_pdf()
|
||||
.bul_table() (ci-dessous)
|
||||
|
||||
"""
|
||||
from reportlab.lib.colors import blue
|
||||
from reportlab.lib.units import cm, mm
|
||||
|
@ -12,7 +26,7 @@ from reportlab.platypus import Paragraph, Spacer
|
|||
|
||||
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
||||
from app.scodoc import gen_tables
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -65,7 +79,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
|
||||
return objects
|
||||
|
||||
def but_table_synthese_ues(self, title_bg=(182, 235, 255)):
|
||||
def but_table_synthese_ues(
|
||||
self, title_bg=(182, 235, 255), title_ue_cap_bg=(150, 207, 147)
|
||||
):
|
||||
"""La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes
|
||||
et leurs coefs.
|
||||
Renvoie: colkeys, P, pdf_style, colWidths
|
||||
|
@ -74,6 +90,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
- pdf_style : commandes table Platypus
|
||||
- largeurs de colonnes pour PDF
|
||||
"""
|
||||
# nb: self.infos a ici été donné par BulletinBUT.bulletin_etud_complet()
|
||||
col_widths = {
|
||||
"titre": None,
|
||||
"min": 1.5 * cm,
|
||||
|
@ -95,6 +112,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
col_keys += ["coef", "moyenne"]
|
||||
# Couleur fond:
|
||||
title_bg = tuple(x / 255.0 for x in title_bg)
|
||||
title_ue_cap_bg = tuple(x / 255.0 for x in title_ue_cap_bg)
|
||||
# elems pour générer table avec gen_table (liste de dicts)
|
||||
rows = [
|
||||
# Ligne de titres
|
||||
|
@ -141,9 +159,17 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
blue,
|
||||
),
|
||||
]
|
||||
|
||||
for ue_acronym, ue in self.infos["ues"].items():
|
||||
self.ue_rows(rows, ue_acronym, ue, title_bg)
|
||||
ues = self.infos["ues"]
|
||||
ues_capitalisees = self.infos.get("ues_capitalisees", {})
|
||||
ues_tup = sorted(
|
||||
list(ues.items()) + list(ues_capitalisees.items()),
|
||||
key=lambda x: x[1]["numero"],
|
||||
)
|
||||
for ue_acronym, ue in ues_tup:
|
||||
is_capitalized = "date_capitalisation" in ue
|
||||
self._ue_rows(
|
||||
rows, ue_acronym, ue, title_ue_cap_bg if is_capitalized else title_bg
|
||||
)
|
||||
|
||||
# Global pdf style commands:
|
||||
pdf_style = [
|
||||
|
@ -152,20 +178,18 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
]
|
||||
return col_keys, rows, pdf_style, col_widths
|
||||
|
||||
def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
|
||||
def _ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
|
||||
"Décrit une UE dans la table synthèse: titre, sous-titre et liste modules"
|
||||
if (ue["type"] == UE_SPORT) and len(ue.get("modules", [])) == 0:
|
||||
# ne mentionne l'UE que s'il y a des modules
|
||||
return
|
||||
# 1er ligne titre UE
|
||||
moy_ue = ue.get("moyenne")
|
||||
moy_ue = ue.get("moyenne", "-")
|
||||
if isinstance(moy_ue, dict):
|
||||
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
|
||||
t = {
|
||||
"titre": f"{ue_acronym} - {ue['titre']}",
|
||||
"moyenne": Paragraph(
|
||||
f"""<para align=right><b>{moy_ue.get("value", "-")
|
||||
if moy_ue is not None else "-"
|
||||
}</b></para>"""
|
||||
),
|
||||
"moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
|
||||
"_css_row_class": "note_bold",
|
||||
"_pdf_row_markup": ["b"],
|
||||
"_pdf_style": [
|
||||
|
@ -196,25 +220,40 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
# case Bonus/Malus/Rang "bmr"
|
||||
fields_bmr = []
|
||||
try:
|
||||
value = float(ue["bonus"])
|
||||
value = float(ue.get("bonus", 0.0))
|
||||
if value != 0:
|
||||
fields_bmr.append(f"Bonus: {ue['bonus']}")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
value = float(ue["malus"])
|
||||
value = float(ue.get("malus", 0.0))
|
||||
if value != 0:
|
||||
fields_bmr.append(f"Malus: {ue['malus']}")
|
||||
except ValueError:
|
||||
pass
|
||||
if self.preferences["bul_show_ue_rangs"]:
|
||||
fields_bmr.append(
|
||||
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
|
||||
|
||||
moy_ue = ue.get("moyenne", "-")
|
||||
if isinstance(moy_ue, dict): # UE non capitalisées
|
||||
if self.preferences["bul_show_ue_rangs"]:
|
||||
fields_bmr.append(
|
||||
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
|
||||
)
|
||||
ue_min, ue_max, ue_moy = (
|
||||
ue["moyenne"]["min"],
|
||||
ue["moyenne"]["max"],
|
||||
ue["moyenne"]["moy"],
|
||||
)
|
||||
else: # UE capitalisée
|
||||
ue_min, ue_max, ue_moy = "", "", moy_ue
|
||||
date_capitalisation = ue.get("date_capitalisation")
|
||||
if date_capitalisation:
|
||||
fields_bmr.append(
|
||||
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
|
||||
)
|
||||
t = {
|
||||
"titre": " - ".join(fields_bmr),
|
||||
"coef": ects_txt,
|
||||
"_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
|
||||
"_coef_pdf": Paragraph(f"""<para align=right>{ects_txt}</para>"""),
|
||||
"_coef_colspan": 2,
|
||||
"_pdf_style": [
|
||||
("BACKGROUND", (0, 0), (-1, 0), title_bg),
|
||||
|
@ -222,9 +261,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
# ligne au dessus du bonus/malus, gris clair
|
||||
("LINEABOVE", (0, 0), (-1, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
|
||||
],
|
||||
"min": ue["moyenne"]["min"],
|
||||
"max": ue["moyenne"]["max"],
|
||||
"moy": ue["moyenne"]["moy"],
|
||||
"min": ue_min,
|
||||
"max": ue_max,
|
||||
"moy": ue_moy,
|
||||
}
|
||||
rows.append(t)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -43,13 +43,13 @@ from app.but import bulletin_but
|
|||
from app.models import FormSemestre, Identite
|
||||
import app.scodoc.sco_utils as scu
|
||||
import app.scodoc.notesdb as ndb
|
||||
from app.scodoc import sco_abs
|
||||
from app.scodoc import sco_codes_parcours
|
||||
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
|
||||
from app.scodoc.sco_xml import quote_xml_attr
|
||||
|
||||
|
||||
def bulletin_but_xml_compat(
|
||||
|
@ -65,11 +65,10 @@ def bulletin_but_xml_compat(
|
|||
from app.scodoc import sco_bulletins
|
||||
|
||||
log(
|
||||
"bulletin_but_xml_compat( formsemestre_id=%s, etudid=%s )"
|
||||
% (formsemestre_id, etudid)
|
||||
f"bulletin_but_xml_compat( formsemestre_id={formsemestre_id}, etudid={etudid} )"
|
||||
)
|
||||
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
etud: Identite = Identite.query.get_or_404(etudid)
|
||||
etud = Identite.get_etud(etudid)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
results = bulletin_but.ResultatsSemestreBUT(formsemestre)
|
||||
nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT]
|
||||
# etat_inscription = etud.inscription_etat(formsemestre.id)
|
||||
|
@ -108,13 +107,13 @@ def bulletin_but_xml_compat(
|
|||
etudid=str(etudid),
|
||||
code_nip=etud.code_nip or "",
|
||||
code_ine=etud.code_ine or "",
|
||||
nom=scu.quote_xml_attr(etud.nom),
|
||||
prenom=scu.quote_xml_attr(etud.prenom),
|
||||
civilite=scu.quote_xml_attr(etud.civilite_str),
|
||||
sexe=scu.quote_xml_attr(etud.civilite_str), # compat
|
||||
photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
|
||||
email=scu.quote_xml_attr(etud.get_first_email() or ""),
|
||||
emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""),
|
||||
nom=quote_xml_attr(etud.nom),
|
||||
prenom=quote_xml_attr(etud.prenom),
|
||||
civilite=quote_xml_attr(etud.civilite_str),
|
||||
sexe=quote_xml_attr(etud.civilite_str), # compat
|
||||
photo_url=quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
|
||||
email=quote_xml_attr(etud.get_first_email() or ""),
|
||||
emailperso=quote_xml_attr(etud.get_first_email("emailperso") or ""),
|
||||
)
|
||||
)
|
||||
# Disponible pour publication ?
|
||||
|
@ -153,13 +152,13 @@ def bulletin_but_xml_compat(
|
|||
x_ue = Element(
|
||||
"ue",
|
||||
id=str(ue.id),
|
||||
numero=scu.quote_xml_attr(ue.numero),
|
||||
acronyme=scu.quote_xml_attr(ue.acronyme or ""),
|
||||
titre=scu.quote_xml_attr(ue.titre or ""),
|
||||
code_apogee=scu.quote_xml_attr(ue.code_apogee or ""),
|
||||
numero=quote_xml_attr(ue.numero),
|
||||
acronyme=quote_xml_attr(ue.acronyme or ""),
|
||||
titre=quote_xml_attr(ue.titre or ""),
|
||||
code_apogee=quote_xml_attr(ue.code_apogee or ""),
|
||||
)
|
||||
doc.append(x_ue)
|
||||
if ue.type != sco_codes_parcours.UE_SPORT:
|
||||
if ue.type != codes_cursus.UE_SPORT:
|
||||
v = results.etud_moy_ue[ue.id][etud.id]
|
||||
vmin = results.etud_moy_ue[ue.id].min()
|
||||
vmax = results.etud_moy_ue[ue.id].max()
|
||||
|
@ -192,11 +191,9 @@ def bulletin_but_xml_compat(
|
|||
code=str(modimpl.module.code or ""),
|
||||
coefficient=str(coef),
|
||||
numero=str(modimpl.module.numero or 0),
|
||||
titre=scu.quote_xml_attr(modimpl.module.titre or ""),
|
||||
abbrev=scu.quote_xml_attr(modimpl.module.abbrev or ""),
|
||||
code_apogee=scu.quote_xml_attr(
|
||||
modimpl.module.code_apogee or ""
|
||||
),
|
||||
titre=quote_xml_attr(modimpl.module.titre or ""),
|
||||
abbrev=quote_xml_attr(modimpl.module.abbrev or ""),
|
||||
code_apogee=quote_xml_attr(modimpl.module.code_apogee or ""),
|
||||
)
|
||||
# XXX TODO rangs et effectifs
|
||||
# --- notes de chaque eval:
|
||||
|
@ -215,7 +212,7 @@ def bulletin_but_xml_compat(
|
|||
coefficient=str(e.coefficient),
|
||||
# pas les poids en XML compat
|
||||
evaluation_type=str(e.evaluation_type),
|
||||
description=scu.quote_xml_attr(e.description),
|
||||
description=quote_xml_attr(e.description),
|
||||
# notes envoyées sur 20, ceci juste pour garder trace:
|
||||
note_max_origin=str(e.note_max),
|
||||
)
|
||||
|
@ -255,14 +252,14 @@ def bulletin_but_xml_compat(
|
|||
):
|
||||
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
|
||||
etudid,
|
||||
formsemestre_id,
|
||||
formsemestre,
|
||||
format="xml",
|
||||
show_uevalid=sco_preferences.get_preference(
|
||||
"bul_show_uevalid", formsemestre_id
|
||||
),
|
||||
)
|
||||
x_situation = Element("situation")
|
||||
x_situation.text = scu.quote_xml_attr(infos["situation"])
|
||||
x_situation.text = quote_xml_attr(infos["situation"])
|
||||
doc.append(x_situation)
|
||||
if dpv:
|
||||
decision = dpv["decisions"][0]
|
||||
|
@ -297,9 +294,9 @@ def bulletin_but_xml_compat(
|
|||
Element(
|
||||
"decision_ue",
|
||||
ue_id=str(ue["ue_id"]),
|
||||
numero=scu.quote_xml_attr(ue["numero"]),
|
||||
acronyme=scu.quote_xml_attr(ue["acronyme"]),
|
||||
titre=scu.quote_xml_attr(ue["titre"]),
|
||||
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"],
|
||||
)
|
||||
)
|
||||
|
@ -322,7 +319,7 @@ def bulletin_but_xml_compat(
|
|||
"appreciation",
|
||||
date=ndb.DateDMYtoISO(appr["date"]),
|
||||
)
|
||||
x_appr.text = scu.quote_xml_attr(appr["comment"])
|
||||
x_appr.text = quote_xml_attr(appr["comment"])
|
||||
doc.append(x_appr)
|
||||
|
||||
if is_appending:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -13,7 +13,7 @@ Classe raccordant avec ScoDoc 7:
|
|||
avec la même interface.
|
||||
|
||||
"""
|
||||
|
||||
import collections
|
||||
from typing import Union
|
||||
|
||||
from flask import g, url_for
|
||||
|
@ -44,15 +44,17 @@ from app.models.formations import Formation
|
|||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.validations import ScolarFormSemestreValidation
|
||||
from app.scodoc import sco_codes_parcours as sco_codes
|
||||
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.codes_cursus import RED, UE_STANDARD
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoException, ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
|
||||
from app.scodoc import sco_cursus_dut
|
||||
|
||||
|
||||
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
|
||||
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
|
||||
|
||||
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
|
||||
super().__init__(etud, formsemestre_id, res)
|
||||
# Ajustements pour le BUT
|
||||
|
@ -65,3 +67,140 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
|
|||
def parcours_validated(self):
|
||||
"True si le parcours est validé"
|
||||
return False # XXX TODO
|
||||
|
||||
|
||||
class EtudCursusBUT:
|
||||
"""L'état de l'étudiant dans son cursus BUT
|
||||
Liste des niveaux validés/à valider
|
||||
"""
|
||||
|
||||
def __init__(self, etud: Identite, formation: Formation):
|
||||
"""formation indique la spécialité préparée"""
|
||||
# Vérifie que l'étudiant est bien inscrit à un sem. de cette formation
|
||||
if formation.id not in (
|
||||
ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions
|
||||
):
|
||||
raise ScoValueError(
|
||||
f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}"
|
||||
)
|
||||
if not formation.referentiel_competence:
|
||||
raise ScoNoReferentielCompetences(formation=formation)
|
||||
#
|
||||
self.etud = etud
|
||||
self.formation = formation
|
||||
self.inscriptions = sorted(
|
||||
[
|
||||
ins
|
||||
for ins in etud.formsemestre_inscriptions
|
||||
if ins.formsemestre.formation.referentiel_competence
|
||||
and (
|
||||
ins.formsemestre.formation.referentiel_competence.id
|
||||
== formation.referentiel_competence.id
|
||||
)
|
||||
],
|
||||
key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut),
|
||||
)
|
||||
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
|
||||
self.parcour: ApcParcours = self.inscriptions[-1].parcour
|
||||
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
|
||||
self.niveaux_by_annee = {}
|
||||
"{ annee : liste des niveaux à valider }"
|
||||
self.niveaux: dict[int, ApcNiveau] = {}
|
||||
"cache les niveaux"
|
||||
for annee in (1, 2, 3):
|
||||
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
|
||||
annee, self.parcour
|
||||
)[1]
|
||||
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
|
||||
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
|
||||
niveaux_d[self.parcour.id] if self.parcour else []
|
||||
)
|
||||
self.niveaux.update(
|
||||
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
|
||||
)
|
||||
# Probablement inutile:
|
||||
# # Cherche les validations de jury enregistrées pour chaque niveau
|
||||
# self.validations_by_niveau = collections.defaultdict(lambda: [])
|
||||
# " { niveau_id : [ ApcValidationRCUE ] }"
|
||||
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
|
||||
# self.validations_by_niveau[validation_rcue.niveau().id].append(
|
||||
# validation_rcue
|
||||
# )
|
||||
# self.validation_by_niveau = {
|
||||
# niveau_id: sorted(
|
||||
# validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code]
|
||||
# )[0]
|
||||
# for niveau_id, validations in self.validations_by_niveau.items()
|
||||
# }
|
||||
# "{ niveau_id : meilleure validation pour ce niveau }"
|
||||
|
||||
self.validation_par_competence_et_annee = {}
|
||||
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
|
||||
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
|
||||
niveau = validation_rcue.niveau()
|
||||
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(
|
||||
niveau.competence.id
|
||||
).get(validation_rcue.annee())
|
||||
# prend la "meilleure" validation
|
||||
if (not previous_validation) or (
|
||||
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
|
||||
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
|
||||
):
|
||||
self.validation_par_competence_et_annee[niveau.competence.id][
|
||||
niveau.annee
|
||||
] = validation_rcue
|
||||
|
||||
self.competences = {
|
||||
competence.id: competence
|
||||
for competence in (
|
||||
self.parcour.query_competences()
|
||||
if self.parcour
|
||||
else self.formation.referentiel_competence.get_competences_tronc_commun()
|
||||
)
|
||||
}
|
||||
"cache { competence_id : competence }"
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
{
|
||||
competence_id : {
|
||||
annee : meilleure_validation
|
||||
}
|
||||
}
|
||||
"""
|
||||
return {
|
||||
competence.id: {
|
||||
annee: self.validation_par_competence_et_annee.get(
|
||||
competence.id, {}
|
||||
).get(annee)
|
||||
for annee in ("BUT1", "BUT2", "BUT3")
|
||||
}
|
||||
for competence in self.competences.values()
|
||||
}
|
||||
|
||||
# XXX TODO OPTIMISATION ACCESS TABLE JURY
|
||||
def to_dict_codes(self) -> dict[int, dict[str, int]]:
|
||||
"""
|
||||
{
|
||||
competence_id : {
|
||||
annee : { validation}
|
||||
}
|
||||
}
|
||||
où validation est un petit dict avec niveau_id, etc.
|
||||
"""
|
||||
d = {}
|
||||
for competence in self.competences.values():
|
||||
d[competence.id] = {}
|
||||
for annee in ("BUT1", "BUT2", "BUT3"):
|
||||
validation_rcue: ApcValidationRCUE = (
|
||||
self.validation_par_competence_et_annee.get(competence.id, {}).get(
|
||||
annee
|
||||
)
|
||||
)
|
||||
|
||||
d[competence.id][annee] = (
|
||||
validation_rcue.to_dict_codes() if validation_rcue else None
|
||||
)
|
||||
return d
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -8,7 +8,7 @@
|
|||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
from wtforms import SelectField, SubmitField
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
from xml.etree import ElementTree
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -26,78 +26,34 @@ def _descr_cursus_but(etud: Identite) -> str:
|
|||
# prend simplement tous les semestre de type APC, ce qui sera faux si
|
||||
# l'étudiant change de spécialité au sein du même département
|
||||
# (ce qui ne peut normalement pas se produire)
|
||||
indices = sorted(
|
||||
inscriptions = sorted(
|
||||
[
|
||||
ins.formsemestre.semestre_id
|
||||
if ins.formsemestre.semestre_id is not None
|
||||
else -1
|
||||
ins
|
||||
for ins in etud.formsemestre_inscriptions
|
||||
if ins.formsemestre.formation.is_apc()
|
||||
]
|
||||
],
|
||||
key=lambda i: i.formsemestre.date_debut,
|
||||
)
|
||||
indices = [
|
||||
ins.formsemestre.semestre_id if ins.formsemestre.semestre_id is not None else -1
|
||||
for ins in inscriptions
|
||||
]
|
||||
|
||||
return ", ".join(f"S{indice}" for indice in indices)
|
||||
|
||||
|
||||
def pvjury_table_but(formsemestre_id: int, format="html"):
|
||||
def pvjury_page_but(formsemestre_id: int, fmt="html"):
|
||||
"""Page récapitulant les décisions de jury BUT
|
||||
formsemestre peut être pair ou impair
|
||||
"""
|
||||
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
assert formsemestre.formation.is_apc()
|
||||
title = "Procès-verbal de jury BUT annuel"
|
||||
|
||||
if format == "html":
|
||||
line_sep = "<br/>"
|
||||
title = "Procès-verbal de jury BUT"
|
||||
if fmt == "html":
|
||||
line_sep = "<br>"
|
||||
else:
|
||||
line_sep = "\n"
|
||||
# remplace pour le BUT la fonction sco_pvjury.pvjury_table
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
titles = {
|
||||
"nom": "Nom",
|
||||
"cursus": "Cursus",
|
||||
"ues": "UE validées",
|
||||
"niveaux": "Niveaux de compétences validés",
|
||||
"decision_but": f"Décision BUT{annee_but}",
|
||||
"diplome": "Résultat au diplôme",
|
||||
"devenir": "Devenir",
|
||||
"observations": "Observations",
|
||||
}
|
||||
rows = []
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
try:
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
if deca.annee_but != annee_but: # wtf ?
|
||||
log(
|
||||
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
|
||||
)
|
||||
continue
|
||||
except ScoValueError:
|
||||
deca = None
|
||||
row = {
|
||||
"nom": etud.etat_civil_pv(line_sep=line_sep),
|
||||
"_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",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
),
|
||||
"cursus": _descr_cursus_but(etud),
|
||||
"ues": deca.descr_ues_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 "",
|
||||
}
|
||||
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda x: x["_nom_order"])
|
||||
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
|
||||
|
||||
# Style excel... passages à la ligne sur \n
|
||||
xls_style_base = sco_excel.excel_make_style()
|
||||
|
@ -109,10 +65,11 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
|
|||
columns_ids=titles.keys(),
|
||||
html_caption=title,
|
||||
html_class="pvjury_table_but table_leftalign",
|
||||
html_title=f"""<div style="margin-bottom: 8px;"><span style="font-size: 120%; font-weight: bold;">{title}</span>
|
||||
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_table_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, format="xlsx")}"
|
||||
<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>
|
||||
|
||||
""",
|
||||
|
@ -136,4 +93,78 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
|
|||
},
|
||||
xls_style_base=xls_style_base,
|
||||
)
|
||||
return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True)
|
||||
return tab.make_page(format=fmt, javascripts=["js/etud_info.js"], init_qtip=True)
|
||||
|
||||
|
||||
def pvjury_table_but(
|
||||
formsemestre: FormSemestre,
|
||||
etudids: list[int] = None,
|
||||
line_sep: str = "\n",
|
||||
only_diplome=False,
|
||||
anonymous=False,
|
||||
with_paragraph_nom=False,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Table avec résultats jury BUT pour PV.
|
||||
Si etudids est None, prend tous les étudiants inscrits.
|
||||
"""
|
||||
# remplace pour le BUT la fonction sco_pv_forms.pvjury_table
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
titles = {
|
||||
"nom": "Code" if anonymous else "Nom",
|
||||
"cursus": "Cursus",
|
||||
"ects": "ECTS",
|
||||
"ues": "UE validées",
|
||||
"niveaux": "Niveaux de compétences validés",
|
||||
"decision_but": f"Décision BUT{annee_but}",
|
||||
"diplome": "Résultat au diplôme",
|
||||
"devenir": "Devenir",
|
||||
"observations": "Observations",
|
||||
}
|
||||
rows = []
|
||||
formsemestre_etudids = formsemestre.etuds_inscriptions.keys()
|
||||
if etudids is None:
|
||||
etudids = formsemestre_etudids
|
||||
for etudid in etudids:
|
||||
if not etudid in formsemestre_etudids:
|
||||
continue # garde fou
|
||||
etud = Identite.get_etud(etudid)
|
||||
try:
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
if deca.annee_but != annee_but: # wtf ?
|
||||
log(
|
||||
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
|
||||
)
|
||||
continue
|
||||
except ScoValueError:
|
||||
deca = None
|
||||
|
||||
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_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",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
),
|
||||
"cursus": _descr_cursus_but(etud),
|
||||
"ects": f"{deca.formsemestre_ects():g}",
|
||||
"ues": deca.descr_ues_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 "",
|
||||
}
|
||||
if deca.valide_diplome() or not only_diplome:
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda x: x["_nom_order"])
|
||||
return rows, titles
|
||||
|
|
|
@ -1,525 +0,0 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Jury BUT: table recap annuelle et liens saisie
|
||||
"""
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
from flask import g, url_for
|
||||
|
||||
from app.but import jury_but
|
||||
from app.but.jury_but import (
|
||||
DecisionsProposeesAnnee,
|
||||
DecisionsProposeesRCUE,
|
||||
DecisionsProposeesUE,
|
||||
)
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.comp import res_sem
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc.sco_codes_parcours import (
|
||||
BUT_BARRE_RCUE,
|
||||
BUT_BARRE_UE,
|
||||
BUT_BARRE_UE8,
|
||||
BUT_RCUE_SUFFISANT,
|
||||
)
|
||||
from app.scodoc import sco_formsemestre_status
|
||||
from app.scodoc import sco_pvjury
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
def formsemestre_saisie_jury_but(
|
||||
formsemestre2: FormSemestre,
|
||||
read_only: bool = False,
|
||||
selected_etudid: int = None,
|
||||
mode="jury",
|
||||
) -> str:
|
||||
"""formsemestre est un semestre PAIR
|
||||
Si readonly, ne montre pas le lien "saisir la décision"
|
||||
|
||||
=> page html complète
|
||||
|
||||
Si mode == "recap", table recap des codes, sans liens de saisie.
|
||||
"""
|
||||
# Quick & Dirty
|
||||
# pour chaque etud de res2 trié
|
||||
# S1: UE1, ..., UEn
|
||||
# S2: UE1, ..., UEn
|
||||
#
|
||||
# UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
|
||||
#
|
||||
# Pour chaque etud de res2 trié
|
||||
# DecisionsProposeesAnnee(etud, formsemestre2)
|
||||
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
|
||||
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
|
||||
if formsemestre2.semestre_id % 2 != 0:
|
||||
raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
|
||||
|
||||
if formsemestre2.formation.referentiel_competence is None:
|
||||
raise ScoValueError(
|
||||
"""
|
||||
<p>Pas de référentiel de compétences associé à la formation !</p>
|
||||
<p>Pour associer un référentiel, passer par le menu <b>Semestre /
|
||||
Voir la formation... </b> et suivre le lien <em>"associer à un référentiel
|
||||
de compétences"</em>
|
||||
"""
|
||||
)
|
||||
|
||||
rows, titles, column_ids = get_jury_but_table(
|
||||
formsemestre2, read_only=read_only, mode=mode
|
||||
)
|
||||
if not rows:
|
||||
return (
|
||||
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
|
||||
)
|
||||
filename = scu.sanitize_filename(
|
||||
f"""jury-but-{formsemestre2.titre_num()}-{time.strftime("%Y-%m-%d")}"""
|
||||
)
|
||||
klass = "table_jury_but_bilan" if mode == "recap" else ""
|
||||
table_html = build_table_jury_but_html(
|
||||
filename, rows, titles, column_ids, selected_etudid=selected_etudid, klass=klass
|
||||
)
|
||||
H = [
|
||||
html_sco_header.sco_header(
|
||||
page_title=f"{formsemestre2.sem_modalite()}: jury BUT annuel",
|
||||
no_side_bar=True,
|
||||
init_qtip=True,
|
||||
javascripts=["js/etud_info.js", "js/table_recap.js"],
|
||||
),
|
||||
sco_formsemestre_status.formsemestre_status_head(
|
||||
formsemestre_id=formsemestre2.id
|
||||
),
|
||||
]
|
||||
if mode == "recap":
|
||||
H.append(
|
||||
f"""<h3>Décisions de jury enregistrées pour les étudiants de ce semestre</h3>
|
||||
<div class="table_jury_but_links">
|
||||
<div>
|
||||
<ul>
|
||||
<li><a href="{url_for(
|
||||
"notes.pvjury_table_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
|
||||
}" class="stdlink">Tableau PV de jury</a>
|
||||
</li>
|
||||
<li><a href="{url_for(
|
||||
"notes.formsemestre_lettres_individuelles",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
|
||||
}" class="stdlink">Courriers individuels (classeur pdf)</a>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
H.append(
|
||||
f"""
|
||||
|
||||
{table_html}
|
||||
|
||||
<div class="table_jury_but_links">
|
||||
"""
|
||||
)
|
||||
|
||||
if (mode == "recap") and not read_only:
|
||||
H.append(
|
||||
f"""
|
||||
<p><a class="stdlink" href="{url_for(
|
||||
"notes.formsemestre_saisie_jury",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
|
||||
}">Saisie des décisions du jury</a>
|
||||
</p>"""
|
||||
)
|
||||
else:
|
||||
H.append(
|
||||
f"""
|
||||
<p><a class="stdlink" href="{url_for(
|
||||
"notes.formsemestre_validation_auto_but",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
|
||||
}">Calcul automatique des décisions du jury</a>
|
||||
</p>
|
||||
<p><a class="stdlink" href="{url_for(
|
||||
"notes.formsemestre_jury_but_recap",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
|
||||
}">Tableau récapitulatif des décisions du jury</a>
|
||||
</p>
|
||||
"""
|
||||
)
|
||||
H.append(
|
||||
f"""
|
||||
</div>
|
||||
|
||||
{html_sco_header.sco_footer()}
|
||||
"""
|
||||
)
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
def build_table_jury_but_html(
|
||||
filename: str, rows, titles, column_ids, selected_etudid: int = None, klass=""
|
||||
) -> str:
|
||||
"""assemble la table html"""
|
||||
footer_rows = [] # inutilisé pour l'instant
|
||||
H = [
|
||||
f"""<div class="table_recap"><table class="table_recap apc jury table_jury_but {klass}"
|
||||
data-filename="{filename}">"""
|
||||
]
|
||||
# header
|
||||
H.append(
|
||||
f"""
|
||||
<thead>
|
||||
{scu.gen_row(column_ids, titles, "th")}
|
||||
</thead>
|
||||
"""
|
||||
)
|
||||
# body
|
||||
H.append("<tbody>")
|
||||
for row in rows:
|
||||
H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n")
|
||||
H.append("</tbody>\n")
|
||||
# footer
|
||||
H.append("<tfoot>")
|
||||
idx_last = len(footer_rows) - 1
|
||||
for i, row in enumerate(footer_rows):
|
||||
H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
|
||||
H.append(
|
||||
"""
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
return "".join(H)
|
||||
|
||||
|
||||
class RowCollector:
|
||||
"""Une ligne de la table"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cells: dict = None,
|
||||
titles: dict = None,
|
||||
convert_values=True,
|
||||
column_classes: dict = None,
|
||||
):
|
||||
self.titles = titles
|
||||
self.row = cells or {} # col_id : str
|
||||
self.column_classes = column_classes # col_id : str, css class
|
||||
self.idx = 0
|
||||
self.last_etud_cell_idx = 0
|
||||
if convert_values:
|
||||
self.fmt_note = scu.fmt_note
|
||||
else:
|
||||
self.fmt_note = lambda x: x
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.row[key] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.row[key]
|
||||
|
||||
def get_row_dict(self):
|
||||
"La ligne, comme un dict"
|
||||
# create empty cells
|
||||
for col_id in self.titles:
|
||||
if col_id not in self.row:
|
||||
self.row[col_id] = ""
|
||||
klass = self.column_classes.get(col_id)
|
||||
if klass:
|
||||
self.row[f"_{col_id}_class"] = klass
|
||||
return self.row
|
||||
|
||||
def add_cell(
|
||||
self,
|
||||
col_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
classes: str = "",
|
||||
idx: int = None,
|
||||
column_class="",
|
||||
):
|
||||
"""Add a row to our table. classes is a list of css class names"""
|
||||
self.idx = idx if idx is not None else self.idx
|
||||
self.row[col_id] = content
|
||||
if classes:
|
||||
self.row[f"_{col_id}_class"] = classes + f" c{self.idx}"
|
||||
if not col_id in self.titles:
|
||||
self.titles[col_id] = title
|
||||
self.titles[f"_{col_id}_col_order"] = self.idx
|
||||
if classes:
|
||||
self.titles[f"_{col_id}_class"] = classes
|
||||
self.column_classes[col_id] = column_class
|
||||
self.idx += 1
|
||||
|
||||
def add_etud_cells(
|
||||
self, etud: Identite, formsemestre: FormSemestre, with_links=True
|
||||
):
|
||||
"Les cells code, nom, prénom etc."
|
||||
# --- Codes (seront cachés, mais exportés en excel)
|
||||
self.add_cell("etudid", "etudid", etud.id, "codes")
|
||||
self.add_cell("code_nip", "code_nip", etud.code_nip or "", "codes")
|
||||
# --- Identité étudiant (adapté de res_comon/get_table_recap, à factoriser XXX TODO)
|
||||
self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
|
||||
self.add_cell("nom_disp", "Nom", etud.nom_disp(), "identite_detail")
|
||||
self["_nom_disp_order"] = etud.sort_key
|
||||
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
|
||||
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
|
||||
if with_links:
|
||||
self["_nom_short_order"] = etud.sort_key
|
||||
self["_nom_short_target"] = url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
)
|
||||
self["_nom_short_target_attrs"] = f'class="etudinfo" id="{etud.id}"'
|
||||
self["_nom_disp_target"] = self["_nom_short_target"]
|
||||
self["_nom_disp_target_attrs"] = self["_nom_short_target_attrs"]
|
||||
self.last_etud_cell_idx = self.idx
|
||||
|
||||
def add_ue_cells(self, dec_ue: DecisionsProposeesUE):
|
||||
"cell de moyenne d'UE"
|
||||
col_id = f"moy_ue_{dec_ue.ue.id}"
|
||||
note_class = ""
|
||||
val = dec_ue.moy_ue
|
||||
if isinstance(val, float):
|
||||
if val < BUT_BARRE_UE:
|
||||
note_class = " moy_inf"
|
||||
elif val >= BUT_BARRE_UE:
|
||||
note_class = " moy_ue_valid"
|
||||
if val < BUT_BARRE_UE8:
|
||||
note_class = " moy_ue_warning" # notes très basses
|
||||
self.add_cell(
|
||||
col_id,
|
||||
dec_ue.ue.acronyme,
|
||||
self.fmt_note(val),
|
||||
"col_ue" + note_class,
|
||||
column_class="col_ue",
|
||||
)
|
||||
self.add_cell(
|
||||
col_id + "_code",
|
||||
dec_ue.ue.acronyme,
|
||||
dec_ue.code_valide or "",
|
||||
"col_ue_code recorded_code",
|
||||
column_class="col_ue",
|
||||
)
|
||||
|
||||
def add_rcue_cells(self, dec_rcue: DecisionsProposeesRCUE):
|
||||
"2 cells: moyenne du RCUE, code enregistré"
|
||||
rcue = dec_rcue.rcue
|
||||
col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}" # le niveau_id
|
||||
note_class = ""
|
||||
val = rcue.moy_rcue
|
||||
if isinstance(val, float):
|
||||
if val < BUT_BARRE_RCUE:
|
||||
note_class = " moy_ue_inf"
|
||||
elif val >= BUT_BARRE_RCUE:
|
||||
note_class = " moy_ue_valid"
|
||||
if val < BUT_RCUE_SUFFISANT:
|
||||
note_class = " moy_ue_warning" # notes très basses
|
||||
self.add_cell(
|
||||
col_id,
|
||||
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
|
||||
self.fmt_note(val),
|
||||
"col_rcue" + note_class,
|
||||
column_class="col_rcue",
|
||||
)
|
||||
self.add_cell(
|
||||
col_id + "_code",
|
||||
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
|
||||
dec_rcue.code_valide or "",
|
||||
"col_rcue_code recorded_code",
|
||||
column_class="col_rcue",
|
||||
)
|
||||
|
||||
def add_nb_rcues_cell(self, deca: DecisionsProposeesAnnee):
|
||||
"cell avec nb niveaux validables / total"
|
||||
klass = " "
|
||||
if deca.nb_rcues_under_8 > 0:
|
||||
klass += "moy_ue_warning"
|
||||
elif deca.nb_validables < deca.nb_competences:
|
||||
klass += "moy_ue_inf"
|
||||
else:
|
||||
klass += "moy_ue_valid"
|
||||
self.add_cell(
|
||||
"rcues_validables",
|
||||
"RCUEs",
|
||||
f"""{deca.nb_validables}/{deca.nb_competences}"""
|
||||
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
|
||||
"col_rcue col_rcues_validables" + klass,
|
||||
)
|
||||
self["_rcues_validables_data"] = {
|
||||
"etudid": deca.etud.id,
|
||||
"nomprenom": deca.etud.nomprenom,
|
||||
}
|
||||
if len(deca.rcues_annee) > 0:
|
||||
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
|
||||
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
|
||||
moy = deca.res_pair.etud_moy_gen[deca.etud.id]
|
||||
if np.isnan(moy):
|
||||
moy_gen_d = "x"
|
||||
else:
|
||||
moy_gen_d = f"{int(moy*1000):05}"
|
||||
else:
|
||||
moy_gen_d = "x"
|
||||
self["_rcues_validables_order"] = f"{deca.nb_validables:04d}-{moy_gen_d}"
|
||||
else:
|
||||
# etudiants sans RCUE: pas de semestre impair, ...
|
||||
# les classe à la fin
|
||||
self[
|
||||
"_rcues_validables_order"
|
||||
] = f"{deca.nb_validables:04d}-00000-{deca.etud.sort_key}"
|
||||
|
||||
|
||||
def get_jury_but_table(
|
||||
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
|
||||
) -> tuple[list[dict], list[str], list[str]]:
|
||||
"""Construit la table des résultats annuels pour le jury BUT"""
|
||||
res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
|
||||
titles = {} # column_id : title
|
||||
column_classes = {}
|
||||
rows = []
|
||||
for etudid in formsemestre2.etuds_inscriptions:
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre2)
|
||||
row = RowCollector(titles=titles, column_classes=column_classes)
|
||||
row.add_etud_cells(etud, formsemestre2, with_links=with_links)
|
||||
row.idx = 100 # laisse place pour les colonnes de groupes
|
||||
# --- Nombre de niveaux
|
||||
row.add_nb_rcues_cell(deca)
|
||||
# --- Les RCUEs
|
||||
for rcue in deca.rcues_annee:
|
||||
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
|
||||
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
|
||||
row.add_ue_cells(deca.decisions_ues[rcue.ue_1.id])
|
||||
row.add_ue_cells(deca.decisions_ues[rcue.ue_2.id])
|
||||
row.add_rcue_cells(dec_rcue)
|
||||
# --- Les ECTS validés
|
||||
ects_valides = 0.0
|
||||
if deca.res_impair:
|
||||
ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
|
||||
if deca.res_pair:
|
||||
ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
|
||||
row.add_cell(
|
||||
"ects_annee",
|
||||
"ECTS",
|
||||
f"""{int(ects_valides)}""",
|
||||
"col_code_annee",
|
||||
)
|
||||
# --- Le code annuel existant
|
||||
row.add_cell(
|
||||
"code_annee",
|
||||
"Année",
|
||||
f"""{deca.code_valide or ''}""",
|
||||
"col_code_annee",
|
||||
)
|
||||
# --- Le lien de saisie
|
||||
if mode != "recap" and with_links:
|
||||
row.add_cell(
|
||||
"lien_saisie",
|
||||
"",
|
||||
f"""
|
||||
<a href="{url_for(
|
||||
'notes.formsemestre_validation_but',
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
formsemestre_id=formsemestre2.id,
|
||||
)}" class="stdlink">
|
||||
{"voir" if read_only else ("modif." if deca.code_valide else "saisie")}
|
||||
décision</a>
|
||||
"""
|
||||
if deca.inscription_etat == scu.INSCRIT
|
||||
else deca.inscription_etat,
|
||||
"col_lien_saisie_but",
|
||||
)
|
||||
rows.append(row)
|
||||
rows_dict = [row.get_row_dict() for row in rows]
|
||||
if len(rows_dict) > 0:
|
||||
res2.recap_add_partitions(rows_dict, titles, col_idx=row.last_etud_cell_idx + 1)
|
||||
column_ids = [title for title in titles if not title.startswith("_")]
|
||||
column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
|
||||
rows_dict.sort(key=lambda row: row["_nom_disp_order"])
|
||||
return rows_dict, titles, column_ids
|
||||
|
||||
|
||||
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
|
||||
"""Liste des résultats jury BUT sous forme de dict, pour API"""
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
|
||||
return []
|
||||
dpv = sco_pvjury.dict_pvjury(formsemestre.id)
|
||||
rows = []
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
rows.append(get_jury_but_etud_result(formsemestre, dpv, etudid))
|
||||
return rows
|
||||
|
||||
|
||||
def get_jury_but_etud_result(
|
||||
formsemestre: FormSemestre, dpv: dict, etudid: int
|
||||
) -> dict:
|
||||
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
dec_etud = dpv["decisions_dict"][etudid]
|
||||
if formsemestre.formation.is_apc():
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
else:
|
||||
deca = None
|
||||
row = {
|
||||
"etudid": etud.id,
|
||||
"code_nip": etud.code_nip,
|
||||
"code_ine": etud.code_ine,
|
||||
"is_apc": dpv["is_apc"], # BUT ou classic ?
|
||||
"etat": dec_etud["etat"], # I ou D ou DEF
|
||||
"nb_competences": deca.nb_competences if deca else 0,
|
||||
}
|
||||
# --- Les RCUEs
|
||||
rcue_list = []
|
||||
if deca:
|
||||
for rcue in deca.rcues_annee:
|
||||
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
|
||||
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
|
||||
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
|
||||
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
|
||||
rcue_dict = {
|
||||
"ue_1": {
|
||||
"ue_id": rcue.ue_1.id,
|
||||
"moy": None
|
||||
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
|
||||
else dec_ue1.moy_ue,
|
||||
"code": dec_ue1.code_valide,
|
||||
},
|
||||
"ue_2": {
|
||||
"ue_id": rcue.ue_2.id,
|
||||
"moy": None
|
||||
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
|
||||
else dec_ue2.moy_ue,
|
||||
"code": dec_ue2.code_valide,
|
||||
},
|
||||
"moy": rcue.moy_rcue,
|
||||
"code": dec_rcue.code_valide,
|
||||
}
|
||||
rcue_list.append(rcue_dict)
|
||||
row["rcues"] = rcue_list
|
||||
# --- Les UEs
|
||||
ue_list = []
|
||||
if dec_etud["decisions_ue"]:
|
||||
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
|
||||
ue_dict = {
|
||||
"ue_id": ue_id,
|
||||
"code": ue_dec["code"],
|
||||
"ects": ue_dec["ects"],
|
||||
}
|
||||
ue_list.append(ue_dict)
|
||||
row["ues"] = ue_list
|
||||
# --- Le semestre (pour les formations classiques)
|
||||
if dec_etud["decision_sem"]:
|
||||
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
|
||||
else:
|
||||
row["semestre"] = {} # APC, ...
|
||||
# --- Autorisations
|
||||
row["autorisations"] = dec_etud["autorisations"]
|
||||
return row
|
|
@ -0,0 +1,94 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Jury BUT et classiques: récupération des résults pour API
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.but import jury_but
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc import sco_pv_dict
|
||||
|
||||
|
||||
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
|
||||
"""Liste des résultats jury BUT sous forme de dict, pour API"""
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
|
||||
return []
|
||||
dpv = sco_pv_dict.dict_pvjury(formsemestre.id)
|
||||
rows = []
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
rows.append(_get_jury_but_etud_result(formsemestre, dpv, etudid))
|
||||
return rows
|
||||
|
||||
|
||||
def _get_jury_but_etud_result(
|
||||
formsemestre: FormSemestre, dpv: dict, etudid: int
|
||||
) -> dict:
|
||||
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
|
||||
etud = Identite.get_etud(etudid)
|
||||
dec_etud = dpv["decisions_dict"][etudid]
|
||||
if formsemestre.formation.is_apc():
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
else:
|
||||
deca = None
|
||||
row = {
|
||||
"etudid": etud.id,
|
||||
"code_nip": etud.code_nip,
|
||||
"code_ine": etud.code_ine,
|
||||
"is_apc": dpv["is_apc"], # BUT ou classic ?
|
||||
"etat": dec_etud["etat"], # I ou D ou DEF
|
||||
"nb_competences": deca.nb_competences if deca else 0,
|
||||
}
|
||||
# --- Les RCUEs
|
||||
rcue_list = []
|
||||
if deca:
|
||||
for rcue in deca.rcues_annee:
|
||||
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
|
||||
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
|
||||
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
|
||||
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
|
||||
rcue_dict = {
|
||||
"ue_1": {
|
||||
"ue_id": rcue.ue_1.id,
|
||||
"moy": None
|
||||
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
|
||||
else dec_ue1.moy_ue,
|
||||
"code": dec_ue1.code_valide,
|
||||
},
|
||||
"ue_2": {
|
||||
"ue_id": rcue.ue_2.id,
|
||||
"moy": None
|
||||
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
|
||||
else dec_ue2.moy_ue,
|
||||
"code": dec_ue2.code_valide,
|
||||
},
|
||||
"moy": rcue.moy_rcue,
|
||||
"code": dec_rcue.code_valide,
|
||||
}
|
||||
rcue_list.append(rcue_dict)
|
||||
row["rcues"] = rcue_list
|
||||
# --- Les UEs
|
||||
ue_list = []
|
||||
if dec_etud["decisions_ue"]:
|
||||
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
|
||||
ue_dict = {
|
||||
"ue_id": ue_id,
|
||||
"code": ue_dec["code"],
|
||||
"ects": ue_dec["ects"],
|
||||
}
|
||||
ue_list.append(ue_dict)
|
||||
row["ues"] = ue_list
|
||||
# --- Le semestre (pour les formations classiques)
|
||||
if dec_etud["decision_sem"]:
|
||||
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
|
||||
else:
|
||||
row["semestre"] = {} # APC, ...
|
||||
# --- Autorisations
|
||||
row["autorisations"] = dec_etud["autorisations"]
|
||||
return row
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -15,20 +15,32 @@ from app.scodoc import sco_cache
|
|||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
def formsemestre_validation_auto_but(formsemestre: FormSemestre) -> int:
|
||||
"""Calcul automatique des décisions de jury sur une année BUT.
|
||||
Returns: nombre d'étudiants "admis"
|
||||
def formsemestre_validation_auto_but(
|
||||
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
|
||||
) -> int:
|
||||
"""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
|
||||
si on a des RCUE "à cheval".
|
||||
- Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux,
|
||||
ce qui est utilisé pour certains tests unitaires).
|
||||
- Normalement, only_adm est True et on n'enregistre que les décisions validantes
|
||||
de droit: ADM ou CMP.
|
||||
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.
|
||||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
raise ScoValueError("fonction réservée aux formations BUT")
|
||||
nb_admis = 0
|
||||
nb_etud_modif = 0
|
||||
with sco_cache.DeferredSemCacheManager():
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
etud = Identite.get_etud(etudid)
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
if deca.admis: # année réussie
|
||||
deca.record_all()
|
||||
nb_admis += 1
|
||||
nb_etud_modif += deca.record_all(
|
||||
no_overwrite=no_overwrite, only_validantes=only_adm
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return nb_admis
|
||||
return nb_etud_modif
|
||||
|
|
|
@ -1,18 +1,42 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Jury BUT: affichage/formulaire
|
||||
"""
|
||||
from flask import g, url_for
|
||||
from app.models.etudiants import Identite
|
||||
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE
|
||||
from app.models import FormSemestre, FormSemestreInscription, UniteEns
|
||||
import re
|
||||
import numpy as np
|
||||
|
||||
import flask
|
||||
from flask import flash, render_template, url_for
|
||||
from flask import g, request
|
||||
|
||||
from app import db
|
||||
from app.but import jury_but
|
||||
from app.but.jury_but import (
|
||||
DecisionsProposeesAnnee,
|
||||
DecisionsProposeesRCUE,
|
||||
DecisionsProposeesUE,
|
||||
)
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import (
|
||||
ApcNiveau,
|
||||
FormSemestre,
|
||||
FormSemestreInscription,
|
||||
Identite,
|
||||
UniteEns,
|
||||
ScolarAutorisationInscription,
|
||||
ScolarFormSemestreValidation,
|
||||
)
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
||||
|
@ -20,35 +44,50 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
Si pas read_only, menus sélection codes jury.
|
||||
"""
|
||||
H = []
|
||||
if deca.code_valide and not read_only:
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre_id,
|
||||
etudid=deca.etud.id)}" class="stdlink">effacer décisions</a>"""
|
||||
else:
|
||||
erase_span = ""
|
||||
|
||||
H.append(
|
||||
f"""<div class="but_section_annee">
|
||||
<div>
|
||||
<b>Décision de jury pour l'année :</b> {
|
||||
_gen_but_select("code_annee", deca.codes, deca.code_valide,
|
||||
disabled=True, klass="manual")
|
||||
}
|
||||
<span>({'non ' if deca.code_valide is None else ''}enregistrée)</span>
|
||||
<span>{erase_span}</span>
|
||||
if deca.jury_annuel:
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_section_annee">
|
||||
<div>
|
||||
<b>Décision de jury pour l'année :</b> {
|
||||
_gen_but_select("code_annee", deca.codes, deca.code_valide,
|
||||
disabled=True, klass="manual")
|
||||
}
|
||||
<span>({deca.code_valide or 'non'} enregistrée)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="but_explanation">{deca.explanation}</div>
|
||||
</div>"""
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
formsemestre_1 = deca.formsemestre_impair
|
||||
formsemestre_2 = deca.formsemestre_pair
|
||||
# Ordonne selon les dates des 2 semestres considérés (pour les redoublants à cheval):
|
||||
reverse_semestre = (
|
||||
deca.formsemestre_pair
|
||||
and deca.formsemestre_impair
|
||||
and deca.formsemestre_pair.date_debut < deca.formsemestre_impair.date_debut
|
||||
)
|
||||
if reverse_semestre:
|
||||
formsemestre_1, formsemestre_2 = formsemestre_2, formsemestre_1
|
||||
H.append(
|
||||
f"""
|
||||
<div><b>Niveaux de compétences et unités d'enseignement :</b></div>
|
||||
<div class="titre_niveaux">
|
||||
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
|
||||
</div>
|
||||
<div class="but_explanation">{deca.explanation}</div>
|
||||
<div class="but_annee">
|
||||
<div class="titre"></div>
|
||||
<div class="titre">S{1}</div>
|
||||
<div class="titre">S{2}</div>
|
||||
<div class="titre">{"S" +str(formsemestre_1.semestre_id)
|
||||
if formsemestre_1 else "-"}
|
||||
<span class="avertissement_redoublement">{formsemestre_1.annee_scolaire_str()
|
||||
if formsemestre_1 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">{"S"+str(formsemestre_2.semestre_id)
|
||||
if formsemestre_2 else "-"}
|
||||
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
|
||||
if formsemestre_2 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">RCUE</div>
|
||||
"""
|
||||
)
|
||||
|
@ -58,42 +97,52 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
|
||||
</div>"""
|
||||
)
|
||||
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id)
|
||||
if dec_rcue is None:
|
||||
break
|
||||
# Semestre impair
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
dec_rcue.rcue.ue_1,
|
||||
dec_rcue.rcue.moy_ue_1,
|
||||
deca.decisions_ues[dec_rcue.rcue.ue_1.id],
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
# Semestre pair
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
dec_rcue.rcue.ue_2,
|
||||
dec_rcue.rcue.moy_ue_2,
|
||||
deca.decisions_ues[dec_rcue.rcue.ue_2.id],
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
# RCUE
|
||||
H.append(
|
||||
f"""<div class="but_niveau_rcue
|
||||
{'recorded' if dec_rcue.code_valide is not None else ''}
|
||||
">
|
||||
<div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
|
||||
<div class="but_code">{
|
||||
_gen_but_select("code_rcue_"+str(niveau.id),
|
||||
dec_rcue.codes,
|
||||
dec_rcue.code_valide,
|
||||
disabled=True, klass="manual"
|
||||
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
|
||||
ues = [
|
||||
ue
|
||||
for ue in deca.ues_impair
|
||||
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
|
||||
]
|
||||
ue_impair = ues[0] if ues else None
|
||||
ues = [
|
||||
ue
|
||||
for ue in deca.ues_pair
|
||||
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
|
||||
]
|
||||
ue_pair = ues[0] if ues else None
|
||||
# Les UEs à afficher,
|
||||
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
|
||||
ues_ro = [
|
||||
(
|
||||
ue_impair,
|
||||
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
|
||||
),
|
||||
(
|
||||
ue_pair,
|
||||
deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
|
||||
),
|
||||
]
|
||||
# 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]
|
||||
# Colonnes d'UE:
|
||||
for ue, ue_read_only in ues_ro:
|
||||
if ue:
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
ue,
|
||||
deca.decisions_ues[ue.id],
|
||||
disabled=read_only or ue_read_only,
|
||||
annee_prec=ue_read_only,
|
||||
niveau_id=ue.niveau_competence.id,
|
||||
)
|
||||
)
|
||||
}</div>
|
||||
</div>"""
|
||||
)
|
||||
else:
|
||||
H.append("""<div class="niveau_vide"></div>""")
|
||||
|
||||
# Colonne RCUE
|
||||
H.append(_gen_but_rcue(dec_rcue, niveau))
|
||||
|
||||
H.append("</div>") # but_annee
|
||||
return "\n".join(H)
|
||||
|
||||
|
@ -104,48 +153,373 @@ def _gen_but_select(
|
|||
code_valide: str,
|
||||
disabled: bool = False,
|
||||
klass: str = "",
|
||||
data: dict = {},
|
||||
) -> str:
|
||||
"Le menu html select avec les codes"
|
||||
h = "\n".join(
|
||||
# if disabled: # mauvaise idée car le disabled est traité en JS
|
||||
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
|
||||
options_htm = "\n".join(
|
||||
[
|
||||
f"""<option value="{code}"
|
||||
f"""<option value="{code}"
|
||||
{'selected' if code == code_valide else ''}
|
||||
class="{'recorded' if code == code_valide else ''}"
|
||||
>{code}</option>"""
|
||||
for code in codes
|
||||
]
|
||||
)
|
||||
return f"""<select required name="{name}"
|
||||
return f"""<select required name="{name}"
|
||||
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);"
|
||||
{"disabled" if disabled else ""}
|
||||
>{h}</select>
|
||||
{" ".join( f'data-{k}="{v}"' for (k,v) in data.items() )}
|
||||
>{options_htm}</select>
|
||||
"""
|
||||
|
||||
|
||||
def _gen_but_niveau_ue(
|
||||
ue: UniteEns, moy_ue: float, dec_ue: DecisionsProposeesUE, disabled=False
|
||||
):
|
||||
ue: UniteEns,
|
||||
dec_ue: DecisionsProposeesUE,
|
||||
disabled: bool = False,
|
||||
annee_prec: bool = False,
|
||||
niveau_id: int = None,
|
||||
) -> 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>"""
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
|
||||
if dec_ue.code_valide:
|
||||
scoplement = f"""<div class="scoplement">
|
||||
<div>Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
|
||||
à {dec_ue.validation.event_date.strftime("%Hh%M")}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
scoplement = ""
|
||||
|
||||
return f"""<div class="but_niveau_ue {
|
||||
'recorded' if dec_ue.code_valide is not None else ''}
|
||||
{'annee_prec' if annee_prec else ''}
|
||||
">
|
||||
<div title="{ue.titre}">{ue.acronyme}</div>
|
||||
<div class="but_note">{scu.fmt_note(moy_ue)}</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),
|
||||
dec_ue.codes,
|
||||
dec_ue.code_valide, disabled=disabled
|
||||
dec_ue.codes,
|
||||
dec_ue.code_valide,
|
||||
disabled=disabled,
|
||||
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
|
||||
)
|
||||
}</div>
|
||||
|
||||
</div>"""
|
||||
|
||||
|
||||
#
|
||||
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
|
||||
if dec_rcue is None:
|
||||
return """
|
||||
<div class="but_niveau_rcue niveau_vide with_scoplement">
|
||||
<div></div>
|
||||
<div class="scoplement">Pas de RCUE (UE non capitalisée ?)</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
scoplement = (
|
||||
f"""<div class="scoplement">{
|
||||
dec_rcue.validation.to_html()
|
||||
}</div>"""
|
||||
if dec_rcue.validation
|
||||
else ""
|
||||
)
|
||||
|
||||
# Déjà enregistré ?
|
||||
niveau_rcue_class = ""
|
||||
if dec_rcue.code_valide is not None and dec_rcue.codes:
|
||||
if dec_rcue.code_valide == dec_rcue.codes[0]:
|
||||
niveau_rcue_class = "recorded"
|
||||
else:
|
||||
niveau_rcue_class = "recorded_different"
|
||||
|
||||
return f"""
|
||||
<div class="but_niveau_rcue {niveau_rcue_class}
|
||||
">
|
||||
<div class="but_note with_scoplement">
|
||||
<div>{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
|
||||
{scoplement}
|
||||
</div>
|
||||
<div class="but_code">
|
||||
{_gen_but_select("code_rcue_"+str(niveau.id),
|
||||
dec_rcue.codes,
|
||||
dec_rcue.code_valide,
|
||||
disabled=True,
|
||||
klass="manual code_rcue",
|
||||
data = { "niveau_id" : str(niveau.id)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
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" 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
|
||||
provisoire pour BUT 2022
|
||||
"""
|
||||
etud: Identite = Identite.query.get_or_404(etudid)
|
||||
etud = Identite.get_etud(etudid)
|
||||
inscriptions = (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
|
@ -162,11 +536,10 @@ def infos_fiche_etud_html(etudid: int) -> str:
|
|||
# temporaire quick & dirty: affiche le dernier
|
||||
try:
|
||||
deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1])
|
||||
if len(deca.rcues_annee) > 0:
|
||||
return f"""<div class="infos_but">
|
||||
return f"""<div class="infos_but">
|
||||
{show_etud(deca, read_only=True)}
|
||||
</div>
|
||||
"""
|
||||
"""
|
||||
except ScoValueError:
|
||||
pass
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -18,21 +18,11 @@ import pandas as pd
|
|||
|
||||
from flask import g
|
||||
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
def get_bonus_sport_class_from_name(dept_id):
|
||||
"""La classe de bonus sport pour le département indiqué.
|
||||
Note: en ScoDoc 9, le bonus sport est défini gloabelement et
|
||||
ne dépend donc pas du département.
|
||||
Résultat: une sous-classe de BonusSport
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class BonusSport:
|
||||
"""Calcul du bonus sport.
|
||||
|
||||
|
@ -65,7 +55,7 @@ class BonusSport:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
formsemestre: FormSemestre,
|
||||
formsemestre: "FormSemestre",
|
||||
sem_modimpl_moys: np.array,
|
||||
ues: list,
|
||||
modimpl_inscr_df: pd.DataFrame,
|
||||
|
@ -362,18 +352,37 @@ class BonusAisneStQuentin(BonusSportAdditif):
|
|||
|
||||
|
||||
class BonusAmiens(BonusSportAdditif):
|
||||
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...).
|
||||
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...)
|
||||
|
||||
Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
|
||||
<p><b>À partir d'août 2022:</b></p>
|
||||
<p>
|
||||
Deux activités optionnelles sont possibles chaque semestre, et peuvent donner lieu à une bonification de 0,1 chacune sur la moyenne de chaque UE.
|
||||
</p><p>
|
||||
La note saisie peut valoir 0 (pas de bonus), 1 (bonus de 0,1 points) ou 2 (bonus de 0,2 points).
|
||||
</p>
|
||||
|
||||
<p><b>Avant juillet 2022:</b></p>
|
||||
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
|
||||
sur toutes les moyennes d'UE.
|
||||
</p>
|
||||
"""
|
||||
|
||||
name = "bonus_amiens"
|
||||
displayed_name = "IUT d'Amiens"
|
||||
seuil_moy_gen = 0.0 # tous les points sont comptés
|
||||
proportion_point = 1e10
|
||||
bonus_max = 0.1
|
||||
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
|
||||
seuil_moy_gen = 0.0 # tous les points sont comptés
|
||||
|
||||
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
|
||||
"""calcul du bonus, avec réglage différent suivant la date"""
|
||||
|
||||
if self.formsemestre.date_debut > datetime.date(2022, 8, 1):
|
||||
self.proportion_point = 0.1
|
||||
self.bonus_max = 0.2
|
||||
else: # anciens semestres
|
||||
self.proportion_point = 1e10
|
||||
self.bonus_max = 0.1
|
||||
|
||||
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
|
||||
|
||||
|
||||
# Finalement ils n'en veulent pas.
|
||||
|
@ -421,6 +430,22 @@ class BonusAmiens(BonusSportAdditif):
|
|||
# )
|
||||
|
||||
|
||||
class BonusBesanconVesoul(BonusSportAdditif):
|
||||
"""Bonus IUT Besançon - Vesoul pour les UE libres
|
||||
|
||||
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
|
||||
sur toutes les moyennes d'UE.
|
||||
</p>
|
||||
"""
|
||||
|
||||
name = "bonus_besancon_vesoul"
|
||||
displayed_name = "IUT de Besançon - Vesoul"
|
||||
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
|
||||
seuil_moy_gen = 0.0 # tous les points sont comptés
|
||||
proportion_point = 1e10 # infini
|
||||
bonus_max = 0.2
|
||||
|
||||
|
||||
class BonusBethune(BonusSportMultiplicatif):
|
||||
"""
|
||||
Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune.
|
||||
|
@ -638,7 +663,10 @@ class BonusCalais(BonusSportAdditif):
|
|||
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 et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b> (ex : UE2.1BS, UE32BS)
|
||||
</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>
|
||||
</ul>
|
||||
"""
|
||||
|
||||
|
@ -649,11 +677,11 @@ class BonusCalais(BonusSportAdditif):
|
|||
proportion_point = 0.06 # 6%
|
||||
|
||||
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
|
||||
parcours = self.formsemestre.formation.get_parcours()
|
||||
parcours = self.formsemestre.formation.get_cursus()
|
||||
# Variantes de DUT ?
|
||||
if (
|
||||
isinstance(parcours, ParcoursDUT)
|
||||
or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS
|
||||
isinstance(parcours, CursusDUT)
|
||||
or parcours.TYPE_CURSUS == CursusDUTMono.TYPE_CURSUS
|
||||
): # DUT
|
||||
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
|
||||
else:
|
||||
|
@ -808,7 +836,7 @@ class BonusLaRochelle(BonusSportAdditif):
|
|||
"""Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle.
|
||||
|
||||
<ul>
|
||||
<li>Si la note de sport est comprise entre 0 et 10 : pas d’ajout de point.</li>
|
||||
<li>Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point.</li>
|
||||
<li>Si la note de sport est comprise entre 10 et 20 :
|
||||
<ul>
|
||||
<li>Pour le BUT, application pour chaque UE du semestre :
|
||||
|
@ -868,15 +896,15 @@ class BonusLeHavre(BonusSportAdditif):
|
|||
<p>
|
||||
Les enseignements optionnels de langue, préprofessionnalisation,
|
||||
PIX (compétences numériques), l'entrepreneuriat étudiant, l'engagement
|
||||
bénévole au sein d’association dès lors qu’une grille d’évaluation des
|
||||
bénévole au sein d'association dès lors qu'une grille d'évaluation des
|
||||
compétences existe ainsi que les activités sportives et culturelles
|
||||
seront traités au niveau semestriel.
|
||||
</p><p>
|
||||
Le maximum de bonification qu’un étudiant peut obtenir sur sa moyenne
|
||||
Le maximum de bonification qu'un étudiant peut obtenir sur sa moyenne
|
||||
est plafonné à 0.5 point.
|
||||
</p><p>
|
||||
Lorsqu’un étudiant suit plus de deux matières qui donnent droit à
|
||||
bonification, l’étudiant choisit les deux notes à retenir.
|
||||
Lorsqu'un étudiant suit plus de deux matières qui donnent droit à
|
||||
bonification, l'étudiant choisit les deux notes à retenir.
|
||||
</p><p>
|
||||
Les points bonus ne sont acquis que pour une note supérieure à 10/20.
|
||||
</p><p>
|
||||
|
@ -885,7 +913,7 @@ class BonusLeHavre(BonusSportAdditif):
|
|||
Pour chaque matière (max. 2) donnant lieu à bonification :<br>
|
||||
|
||||
Bonification = (N-10) x 0,05,
|
||||
N étant la note de l’activité sur 20.
|
||||
N étant la note de l'activité sur 20.
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
@ -1097,13 +1125,13 @@ class BonusOrleans(BonusSportAdditif):
|
|||
"""Calcul bonus modules optionnels (sport, culture), règle IUT d'Orléans
|
||||
<p><b>Cadre général :</b>
|
||||
En reconnaissance de l'engagement des étudiants dans la vie associative,
|
||||
sociale ou professionnelle, l’IUT d’Orléans accorde, sous conditions,
|
||||
sociale ou professionnelle, l'IUT d'Orléans accorde, sous conditions,
|
||||
une bonification aux étudiants inscrits qui en font la demande en début
|
||||
d’année universitaire.
|
||||
d'année universitaire.
|
||||
</p>
|
||||
<p>Cet engagement doit être régulier et correspondre à une activité réelle
|
||||
et sérieuse qui bénéficie à toute la communauté étudiante de l’IUT,
|
||||
de l’Université ou à l’ensemble de la collectivité.</p>
|
||||
et sérieuse qui bénéficie à toute la communauté étudiante de l'IUT,
|
||||
de l'Université ou à l'ensemble de la collectivité.</p>
|
||||
<p><b>Bonification :</b>
|
||||
Pour les DUT et LP, cette bonification interviendra sur la moyenne générale
|
||||
des semestres pairs :
|
||||
|
@ -1169,6 +1197,89 @@ class BonusRoanne(BonusSportAdditif):
|
|||
proportion_point = 1
|
||||
|
||||
|
||||
class BonusSceaux(BonusSportAdditif): # atypique
|
||||
"""IUT de Sceaux
|
||||
|
||||
L’IUT de Sceaux (Université de Paris-Saclay) propose aux étudiants un seul enseignement
|
||||
non rattaché aux UE : l’option Sport.
|
||||
<p>
|
||||
Cette option donne à l’étudiant qui la suit une bonification qui s’applique uniquement
|
||||
si sa note est supérieure à 10.
|
||||
</p>
|
||||
<p>
|
||||
Cette bonification s’applique sur l’ensemble des UE d’un semestre de la façon suivante :
|
||||
</p>
|
||||
<p>
|
||||
<tt>
|
||||
[ (Note – 10) / Nb UE du semestre ] / Total des coefficients de chaque UE
|
||||
</tt>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Exemple : un étudiant qui a obtenu 16/20 à l’option Sport en S1
|
||||
(composé par exemple de 3 UE:UE1.1, UE1.2 et UE1.3)
|
||||
aurait les bonifications suivantes :
|
||||
</p>
|
||||
<ul>
|
||||
<li>UE1.1 (Total des coefficients : 15) ⇒ Bonification UE1.1 = <tt>[ (16 – 10) / 3 ] /15
|
||||
</tt>
|
||||
</li>
|
||||
<li>UE1.2 (Total des coefficients : 14) ⇒ Bonification UE1.2 = <tt>[ (16 – 10) / 3 ] /14
|
||||
</tt>
|
||||
</li>
|
||||
<li>UE1.3 (Total des coefficients : 12,5) ⇒ Bonification UE1.3 = <tt>[ (16 – 10) / 3 ] /12,5
|
||||
</tt>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
|
||||
name = "bonus_iut_sceaux"
|
||||
displayed_name = "IUT de Sceaux"
|
||||
proportion_point = 1.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formsemestre: "FormSemestre",
|
||||
sem_modimpl_moys: np.array,
|
||||
ues: list,
|
||||
modimpl_inscr_df: pd.DataFrame,
|
||||
modimpl_coefs: np.array,
|
||||
etud_moy_gen,
|
||||
etud_moy_ue,
|
||||
):
|
||||
# Pour ce bonus, il faut conserver:
|
||||
# - le nombre d'UEs
|
||||
self.nb_ues = len([ue for ue in ues if ue.type != UE_SPORT])
|
||||
# - le total des coefs de chaque UE
|
||||
# modimpl_coefs : DataFrame, lignes modimpl, col UEs (sans sport)
|
||||
self.sum_coefs_ues = modimpl_coefs.sum() # Series, index ue_id
|
||||
super().__init__(
|
||||
formsemestre,
|
||||
sem_modimpl_moys,
|
||||
ues,
|
||||
modimpl_inscr_df,
|
||||
modimpl_coefs,
|
||||
etud_moy_gen,
|
||||
etud_moy_ue,
|
||||
)
|
||||
|
||||
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
|
||||
"""Calcul du bonus IUT de Sceaux 2023
|
||||
sem_modimpl_moys_inscrits: les notes de sport
|
||||
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
|
||||
En classic: ndarray (nb_etuds, nb_mod_sport)
|
||||
|
||||
Attention: si la somme des coefs de modules dans une UE est nulle, on a un bonus Inf
|
||||
(moyenne d'UE cappée à 20).
|
||||
"""
|
||||
if (0 in sem_modimpl_moys_inscrits.shape) or (self.nb_ues == 0):
|
||||
# pas d'étudiants ou pas d'UE ou pas de module...
|
||||
return
|
||||
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
|
||||
if self.bonus_ues is not None:
|
||||
self.bonus_ues = (self.bonus_ues / self.nb_ues) / self.sum_coefs_ues
|
||||
|
||||
|
||||
class BonusStEtienne(BonusSportAdditif):
|
||||
"""IUT de Saint-Etienne.
|
||||
|
||||
|
@ -1199,7 +1310,7 @@ class BonusStDenis(BonusSportAdditif):
|
|||
bonus_max = 0.5
|
||||
|
||||
|
||||
class BonusStNazaire(BonusSportMultiplicatif):
|
||||
class BonusStNazaire(BonusSport):
|
||||
"""IUT de Saint-Nazaire
|
||||
|
||||
Trois bonifications sont possibles : sport, culture et engagement citoyen
|
||||
|
@ -1221,9 +1332,37 @@ class BonusStNazaire(BonusSportMultiplicatif):
|
|||
name = "bonus_iutSN"
|
||||
displayed_name = "IUT de Saint-Nazaire"
|
||||
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
|
||||
seuil_moy_gen = 0.0 # tous les points comptent
|
||||
amplitude = 0.01 / 4 # 4pt => 1%
|
||||
factor_max = 0.1 # 10% max
|
||||
# Modifié 2022-11-29: calculer chaque bonus
|
||||
# (de 1 à 3 modules) séparément.
|
||||
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
|
||||
"""Calcul du bonus St Nazaire 2022
|
||||
sem_modimpl_moys_inscrits: les notes de sport
|
||||
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
|
||||
En classic: ndarray (nb_etuds, nb_mod_sport)
|
||||
"""
|
||||
if 0 in sem_modimpl_moys_inscrits.shape:
|
||||
# pas d'étudiants ou pas d'UE ou pas de module...
|
||||
return
|
||||
# Prend les 3 premiers bonus trouvés
|
||||
# ignore les coefficients
|
||||
bonus_mod_moys = sem_modimpl_moys_inscrits[:, :3]
|
||||
bonus_mod_moys = np.nan_to_num(bonus_mod_moys, copy=False)
|
||||
factor = bonus_mod_moys * self.amplitude
|
||||
# somme les bonus:
|
||||
factor = factor.sum(axis=1)
|
||||
# et limite à 10%:
|
||||
factor.clip(0.0, self.factor_max, out=factor)
|
||||
|
||||
# Applique aux moyennes d'UE
|
||||
if len(factor.shape) == 1: # classic
|
||||
factor = factor[:, np.newaxis]
|
||||
bonus = self.etud_moy_ue * factor
|
||||
self.bonus_ues = bonus # DataFrame
|
||||
|
||||
# Les bonus multiplicatifs ne s'appliquent pas à la moyenne générale
|
||||
self.bonus_moy_gen = None
|
||||
|
||||
|
||||
class BonusTarbes(BonusIUTRennes1):
|
||||
|
@ -1302,7 +1441,45 @@ class BonusIUTvannes(BonusSportAdditif):
|
|||
classic_use_bonus_ues = False # seulement sur moy gen.
|
||||
|
||||
|
||||
class BonusVilleAvray(BonusSport):
|
||||
class BonusValenciennes(BonusDirect):
|
||||
"""Article 7 des RCC de l'IUT de Valenciennes
|
||||
|
||||
<p>
|
||||
Une bonification maximale de 0.25 point (1/4 de point) peut être ajoutée
|
||||
à la moyenne de chaque Unité d'Enseignement pour :
|
||||
</p>
|
||||
<ul>
|
||||
<li>l'engagement citoyen ;</li>
|
||||
<li>la participation à un module de sport.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Une bonification accordée par la commission des sports de l'UPHF peut être attribuée
|
||||
aux sportifs de haut niveau. Cette bonification est appliquée à l'ensemble des
|
||||
Unités d'Enseignement. Ce bonus est :
|
||||
</p>
|
||||
<ul>
|
||||
<li> 0.5 pour la catégorie <em>or</em> (sportif inscrit sur liste ministérielle
|
||||
jeunesse et sport) ;
|
||||
</li>
|
||||
<li> 0.45 pour la catégorie <em>argent</em> (sportif en club professionnel) ;
|
||||
</li>
|
||||
<li> 0.40 pour le <em>bronze</em> (sportif de niveau départemental, régional ou national).
|
||||
</li>
|
||||
</ul>
|
||||
<p>Le cumul de bonifications est possible mais ne peut excéder 0.5 point (un demi-point).
|
||||
</p>
|
||||
<p><em>Dans ScoDoc, saisir directement la valeur désirée du bonus
|
||||
dans une évaluation notée sur 20.</em>
|
||||
</p>
|
||||
"""
|
||||
|
||||
name = "bonus_valenciennes"
|
||||
displayed_name = "IUT de Valenciennes"
|
||||
bonus_max = 0.5
|
||||
|
||||
|
||||
class BonusVilleAvray(BonusSportAdditif):
|
||||
"""Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray.
|
||||
|
||||
Les étudiants de l'IUT peuvent suivre des enseignements optionnels
|
||||
|
@ -1351,7 +1528,7 @@ class BonusIUTV(BonusSportAdditif):
|
|||
|
||||
name = "bonus_iutv"
|
||||
displayed_name = "IUT de Villetaneuse"
|
||||
pass # oui, c'est le bonus par défaut
|
||||
# c'est le bonus par défaut: aucune méthode à surcharger
|
||||
|
||||
|
||||
def get_bonus_class_dict(start=BonusSport, d=None):
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -9,10 +9,10 @@
|
|||
import pandas as pd
|
||||
|
||||
from app import db
|
||||
from app.models import FormSemestre, ScolarFormSemestreValidation, UniteEns
|
||||
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
|
||||
from app.comp.res_cache import ResultatsCache
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import codes_cursus
|
||||
|
||||
|
||||
class ValidationsSemestre(ResultatsCache):
|
||||
|
@ -53,7 +53,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
self.comp_decisions_jury()
|
||||
|
||||
def comp_decisions_jury(self):
|
||||
"""Cherche les decisions du jury pour le semestre (pas les UE).
|
||||
"""Cherche les decisions du jury pour le semestre (pas les RCUE).
|
||||
Calcule les attributs:
|
||||
decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
|
||||
decision_jury_ues={ etudid :
|
||||
|
@ -89,7 +89,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
if decision.etudid not in decisions_jury_ues:
|
||||
decisions_jury_ues[decision.etudid] = {}
|
||||
# Calcul des ECTS associés à cette UE:
|
||||
if sco_codes_parcours.code_ue_validant(decision.code) and decision.ue:
|
||||
if codes_cursus.code_ue_validant(decision.code) and decision.ue:
|
||||
ects = decision.ue.ects or 0.0 # 0 if None
|
||||
else:
|
||||
ects = 0.0
|
||||
|
@ -102,6 +102,12 @@ class ValidationsSemestre(ResultatsCache):
|
|||
|
||||
self.decisions_jury_ues = decisions_jury_ues
|
||||
|
||||
def has_decision(self, etud: Identite) -> bool:
|
||||
"""Vrai si etud a au moins une décision enregistrée depuis
|
||||
ce semestre (quelle qu'elle soit)
|
||||
"""
|
||||
return (etud.id in self.decisions_jury_ues) or (etud.id in self.decisions_jury)
|
||||
|
||||
|
||||
def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame:
|
||||
"""Liste des UE capitalisées (ADM) utilisables dans ce formsemestre
|
||||
|
@ -122,6 +128,10 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
|
|||
event_date :
|
||||
} ]
|
||||
"""
|
||||
|
||||
# Note: pour récupérer aussi les UE validées en CMp ou ADJ, changer une ligne
|
||||
# and ( SFV.code = 'ADM' or SFV.code = 'ADJ' or SFV.code = 'CMP' )
|
||||
|
||||
query = """
|
||||
SELECT DISTINCT SFV.*, ue.ue_code
|
||||
FROM
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -14,7 +14,7 @@ import pandas as pd
|
|||
from app.comp import moy_ue
|
||||
from app.models.formsemestre import FormSemestre
|
||||
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -35,14 +35,16 @@ moyenne générale d'une UE.
|
|||
"""
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.models import ModuleImpl, Evaluation, EvaluationUEPoids
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoBugCatcher
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
@ -83,6 +85,8 @@ class ModuleImplResults:
|
|||
"{ evaluation.id : bool } indique si à prendre en compte ou non."
|
||||
self.evaluations_etat = {}
|
||||
"{ evaluation_id: EvaluationEtat }"
|
||||
self.etudids_attente = set()
|
||||
"etudids avec au moins une note ATT dans ce module"
|
||||
self.en_attente = False
|
||||
"Vrai si au moins une évaluation a une note en attente"
|
||||
#
|
||||
|
@ -143,7 +147,6 @@ class ModuleImplResults:
|
|||
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
|
||||
self.evaluations_completes = []
|
||||
self.evaluations_completes_dict = {}
|
||||
self.en_attente = False
|
||||
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
|
||||
|
@ -170,15 +173,20 @@ class ModuleImplResults:
|
|||
eval_df, how="left", left_index=True, right_index=True
|
||||
)
|
||||
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
|
||||
nb_att = sum(
|
||||
evals_notes[str(evaluation.id)][list(inscrits_module)]
|
||||
== scu.NOTES_ATTENTE
|
||||
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
|
||||
)
|
||||
self.etudids_attente |= eval_etudids_attente
|
||||
self.evaluations_etat[evaluation.id] = EvaluationEtat(
|
||||
evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
|
||||
evaluation_id=evaluation.id,
|
||||
nb_attente=len(eval_etudids_attente),
|
||||
is_complete=is_complete,
|
||||
)
|
||||
if nb_att > 0:
|
||||
self.en_attente = True
|
||||
# au moins une note en ATT dans ce modimpl:
|
||||
self.en_attente = bool(self.etudids_attente)
|
||||
|
||||
# Force columns names to integers (evaluation ids)
|
||||
evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int)
|
||||
|
@ -217,12 +225,19 @@ class ModuleImplResults:
|
|||
]
|
||||
|
||||
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
|
||||
"""Coefficients des évaluations, met à zéro ceux des évals incomplètes.
|
||||
"""Coefficients des évaluations.
|
||||
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
|
||||
sont zéro.
|
||||
Résultat: 2d-array of floats, shape (nb_evals, 1)
|
||||
"""
|
||||
return (
|
||||
np.array(
|
||||
[e.coefficient for e in moduleimpl.evaluations],
|
||||
[
|
||||
e.coefficient
|
||||
if e.evaluation_type == scu.EVALUATION_NORMALE
|
||||
else 0.0
|
||||
for e in moduleimpl.evaluations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
* self.evaluations_completes
|
||||
|
@ -236,8 +251,8 @@ class ModuleImplResults:
|
|||
]
|
||||
|
||||
def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
|
||||
"""Les notes des évaluations,
|
||||
remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
|
||||
"""Les notes de toutes les évaluations du module, complètes ou non.
|
||||
Remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
|
||||
Résultat: 2d array of floats, shape nb_etuds x nb_evaluations
|
||||
"""
|
||||
return np.where(
|
||||
|
@ -368,7 +383,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
etuds_moy_module = np.where(
|
||||
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
|
||||
)
|
||||
# Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE:
|
||||
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
|
||||
self.etuds_use_rattrapage = pd.Series(
|
||||
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
|
||||
)
|
||||
|
@ -429,7 +444,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
|
|||
|
||||
|
||||
def moduleimpl_is_conforme(
|
||||
moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame
|
||||
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
|
||||
) -> bool:
|
||||
"""Vérifie que les évaluations de ce moduleimpl sont bien conformes
|
||||
au PN.
|
||||
|
@ -438,7 +453,7 @@ def moduleimpl_is_conforme(
|
|||
|
||||
Arguments:
|
||||
evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
|
||||
modules_coefficients: DataFrame, cols module_id, lignes UEs
|
||||
modimpl_coefs_df: DataFrame, cols: modimpl_id, lignes: UEs du formsemestre
|
||||
NB: les UEs dans evals_poids sont sans le bonus sport
|
||||
"""
|
||||
nb_evals, nb_ues = evals_poids.shape
|
||||
|
@ -446,18 +461,18 @@ def moduleimpl_is_conforme(
|
|||
return True # modules vides conformes
|
||||
if nb_ues == 0:
|
||||
return False # situation absurde (pas d'UE)
|
||||
if len(modules_coefficients) != nb_ues:
|
||||
if len(modimpl_coefs_df) != nb_ues:
|
||||
# il arrive (#bug) que le cache ne soit pas à jour...
|
||||
sco_cache.invalidate_formsemestre()
|
||||
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
|
||||
|
||||
if moduleimpl.module_id not in modules_coefficients:
|
||||
if moduleimpl.id not in modimpl_coefs_df:
|
||||
# soupçon de bug cache coef ?
|
||||
sco_cache.invalidate_formsemestre()
|
||||
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
|
||||
|
||||
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
|
||||
return all((modules_coefficients[moduleimpl.module_id] != 0).eq(module_evals_poids))
|
||||
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
|
||||
|
||||
|
||||
class ModuleImplResultsClassic(ModuleImplResults):
|
||||
|
@ -476,7 +491,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
|
|||
if nb_etuds == 0:
|
||||
return pd.Series()
|
||||
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
|
||||
assert evals_coefs.shape == (nb_evals,)
|
||||
if evals_coefs.shape != (nb_evals,):
|
||||
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
|
||||
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
|
||||
# Les coefs des évals pour chaque étudiant: là où il a des notes
|
||||
# non neutralisées
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -52,13 +52,16 @@ def compute_sem_moys_apc_using_coefs(
|
|||
|
||||
|
||||
def compute_sem_moys_apc_using_ects(
|
||||
etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False
|
||||
etud_moy_ue_df: pd.DataFrame,
|
||||
ects_df: pd.DataFrame,
|
||||
formation_id=None,
|
||||
skip_empty_ues=False,
|
||||
) -> pd.Series:
|
||||
"""Calcule les moyennes générales indicatives de tous les étudiants
|
||||
= moyenne des moyennes d'UE, pondérée par leurs ECTS.
|
||||
|
||||
etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid
|
||||
ects: liste de floats ou None, 1 par UE
|
||||
ects: DataFrame, col. ue_id, lignes etudid, valeur float ou None
|
||||
|
||||
Si skip_empty_ues: ne compte pas les UE non notées.
|
||||
Sinon (par défaut), une UE non notée compte comme zéro.
|
||||
|
@ -68,11 +71,11 @@ def compute_sem_moys_apc_using_ects(
|
|||
try:
|
||||
if skip_empty_ues:
|
||||
# annule les coefs des UE sans notes (NaN)
|
||||
ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float))
|
||||
# ects est devenu nb_etuds x nb_ues
|
||||
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
|
||||
ects = np.where(etud_moy_ue_df.isna(), 0.0, ects_df.to_numpy())
|
||||
else:
|
||||
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects)
|
||||
ects = ects_df.to_numpy()
|
||||
# ects est maintenant un array nb_etuds x nb_ues
|
||||
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
|
||||
except TypeError:
|
||||
if None in ects:
|
||||
formation = Formation.query.get(formation_id)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -32,12 +32,17 @@ import pandas as pd
|
|||
|
||||
from app import db
|
||||
from app import models
|
||||
from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef
|
||||
from app.models import (
|
||||
FormSemestre,
|
||||
Module,
|
||||
ModuleImpl,
|
||||
ModuleUECoef,
|
||||
UniteEns,
|
||||
)
|
||||
from app.comp import moy_mod
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
|
@ -56,7 +61,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
|
|||
"""
|
||||
ues = (
|
||||
UniteEns.query.filter_by(formation_id=formation_id)
|
||||
.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
|
||||
.filter(UniteEns.type != codes_cursus.UE_SPORT)
|
||||
.order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
|
||||
)
|
||||
modules = (
|
||||
|
@ -64,14 +69,9 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
|
|||
.filter(
|
||||
(Module.module_type == ModuleType.RESSOURCE)
|
||||
| (Module.module_type == ModuleType.SAE)
|
||||
| (
|
||||
(Module.ue_id == UniteEns.id)
|
||||
& (UniteEns.type == sco_codes_parcours.UE_SPORT)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code
|
||||
| ((Module.ue_id == UniteEns.id) & (UniteEns.type == codes_cursus.UE_SPORT))
|
||||
)
|
||||
.order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code)
|
||||
)
|
||||
if semestre_idx is not None:
|
||||
ues = ues.filter_by(semestre_idx=semestre_idx)
|
||||
|
@ -140,7 +140,8 @@ def df_load_modimpl_coefs(
|
|||
mod_coef.ue_id
|
||||
] = mod_coef.coef
|
||||
except IndexError:
|
||||
# il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation
|
||||
# il peut y avoir en base des coefs sur des modules ou UE
|
||||
# qui ont depuis été retirés de la formation
|
||||
pass
|
||||
# Initialisation des poids non fixés:
|
||||
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
|
||||
|
@ -199,7 +200,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
|
|||
modimpls_results[modimpl.id] = mod_results
|
||||
modimpls_evals_poids[modimpl.id] = evals_poids
|
||||
modimpls_notes.append(etuds_moy_module)
|
||||
if len(modimpls_notes):
|
||||
if len(modimpls_notes) > 0:
|
||||
cube = notes_sem_assemble_cube(modimpls_notes)
|
||||
else:
|
||||
nb_etuds = formsemestre.etuds.count()
|
||||
|
@ -215,10 +216,11 @@ def compute_ue_moys_apc(
|
|||
sem_cube: np.array,
|
||||
etuds: list,
|
||||
modimpls: list,
|
||||
ues: list,
|
||||
modimpl_inscr_df: pd.DataFrame,
|
||||
modimpl_coefs_df: pd.DataFrame,
|
||||
modimpl_mask: np.array,
|
||||
dispense_ues: set[tuple[int, int]],
|
||||
block: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
"""Calcul de la moyenne d'UE en mode APC (BUT).
|
||||
La moyenne d'UE est un nombre (note/20), ou NaN si pas de notes disponibles
|
||||
|
@ -229,18 +231,17 @@ def compute_ue_moys_apc(
|
|||
etuds : liste des étudiants (dim. 0 du cube)
|
||||
modimpls : liste des module_impl (dim. 1 du cube)
|
||||
ues : liste des UE (dim. 2 du cube)
|
||||
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
|
||||
modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl)
|
||||
modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport
|
||||
modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
|
||||
(utilisé pour éliminer les bonus, et pourra servir à cacluler
|
||||
sur des sous-ensembles de modules)
|
||||
|
||||
block: si vrai, ne calcule rien et renvoie des NaNs
|
||||
Résultat: DataFrame columns UE (sans bonus), rows etudid
|
||||
"""
|
||||
nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
|
||||
nb_ues_tot = len(ues)
|
||||
assert len(modimpls) == nb_modules
|
||||
if nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
|
||||
if block or nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
|
||||
return pd.DataFrame(
|
||||
index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index
|
||||
)
|
||||
|
@ -277,11 +278,16 @@ def compute_ue_moys_apc(
|
|||
etud_moy_ue = np.sum(
|
||||
modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1
|
||||
) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
|
||||
return pd.DataFrame(
|
||||
etud_moy_ue_df = pd.DataFrame(
|
||||
etud_moy_ue,
|
||||
index=modimpl_inscr_df.index, # les etudids
|
||||
columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport
|
||||
)
|
||||
# Les "dispenses" sont très peu nombreuses et traitées en python:
|
||||
for dispense_ue in dispense_ues:
|
||||
etud_moy_ue_df[dispense_ue[1]][dispense_ue[0]] = 0.0
|
||||
|
||||
return etud_moy_ue_df
|
||||
|
||||
|
||||
def compute_ue_moys_classic(
|
||||
|
@ -291,6 +297,7 @@ def compute_ue_moys_classic(
|
|||
modimpl_inscr_df: pd.DataFrame,
|
||||
modimpl_coefs: np.array,
|
||||
modimpl_mask: np.array,
|
||||
block: bool = False,
|
||||
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]:
|
||||
"""Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...).
|
||||
|
||||
|
@ -312,6 +319,7 @@ def compute_ue_moys_classic(
|
|||
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
|
||||
modimpl_coefs: vecteur des coefficients de modules
|
||||
modimpl_mask: masque des modimpls à prendre en compte
|
||||
block: si vrai, ne calcule rien et renvoie des NaNs
|
||||
|
||||
Résultat:
|
||||
- moyennes générales: pd.Series, index etudid
|
||||
|
@ -320,13 +328,14 @@ def compute_ue_moys_classic(
|
|||
les coefficients effectifs de chaque UE pour chaque étudiant
|
||||
(sommes de coefs de modules pris en compte)
|
||||
"""
|
||||
if (not len(modimpl_mask)) or (
|
||||
sem_matrix.shape[0] == 0
|
||||
if (
|
||||
block or (len(modimpl_mask) == 0) or (sem_matrix.shape[0] == 0)
|
||||
): # aucun module ou aucun étudiant
|
||||
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
|
||||
val = np.nan if block else 0.0
|
||||
return (
|
||||
pd.Series(
|
||||
[0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
|
||||
[val] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
|
||||
),
|
||||
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
|
||||
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
|
||||
|
@ -431,7 +440,7 @@ def compute_mat_moys_classic(
|
|||
Résultat:
|
||||
- moyennes: pd.Series, index etudid
|
||||
"""
|
||||
if (not len(modimpl_mask)) or (
|
||||
if (0 == len(modimpl_mask)) or (
|
||||
sem_matrix.shape[0] == 0
|
||||
): # aucun module ou aucun étudiant
|
||||
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
|
||||
|
@ -462,9 +471,10 @@ def compute_mat_moys_classic(
|
|||
if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides
|
||||
modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
|
||||
|
||||
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
|
||||
axis=1
|
||||
) / modimpl_coefs_etuds_no_nan.sum(axis=1)
|
||||
with np.errstate(invalid="ignore"): # il peut y avoir des NaN
|
||||
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
|
||||
axis=1
|
||||
) / modimpl_coefs_etuds_no_nan.sum(axis=1)
|
||||
|
||||
return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -16,9 +16,11 @@ from app.comp.res_compat import NotesTableCompat
|
|||
from app.comp.bonus_spo import BonusSport
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.models.ues import DispenseUE, UniteEns
|
||||
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class ResultatsSemestreBUT(NotesTableCompat):
|
||||
|
@ -39,6 +41,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
"""ndarray (etuds x modimpl x ue)"""
|
||||
self.etuds_parcour_id = None
|
||||
"""Parcours de chaque étudiant { etudid : parcour_id }"""
|
||||
|
||||
if not self.load_cached():
|
||||
t0 = time.time()
|
||||
self.compute()
|
||||
|
@ -71,15 +74,18 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
modimpl.module.ue.type != UE_SPORT
|
||||
for modimpl in self.formsemestre.modimpls_sorted
|
||||
]
|
||||
|
||||
self.dispense_ues = DispenseUE.load_formsemestre_dispense_ues_set(
|
||||
self.formsemestre, self.modimpl_inscr_df.index, self.ues
|
||||
)
|
||||
self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
|
||||
self.sem_cube,
|
||||
self.etuds,
|
||||
self.formsemestre.modimpls_sorted,
|
||||
self.ues,
|
||||
self.modimpl_inscr_df,
|
||||
self.modimpl_coefs_df,
|
||||
modimpls_mask,
|
||||
self.dispense_ues,
|
||||
block=self.formsemestre.block_moyennes,
|
||||
)
|
||||
# Les coefficients d'UE ne sont pas utilisés en APC
|
||||
self.etud_coef_ue_df = pd.DataFrame(
|
||||
|
@ -114,6 +120,10 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
|
||||
# Nanifie les moyennes d'UE hors parcours pour chaque étudiant
|
||||
self.etud_moy_ue *= self.ues_inscr_parcours_df
|
||||
# Les ects (utilisés comme coefs) sont nuls pour les UE hors parcours:
|
||||
ects = self.ues_inscr_parcours_df.fillna(0.0) * [
|
||||
ue.ects for ue in self.ues if ue.type != UE_SPORT
|
||||
]
|
||||
|
||||
# Moyenne générale indicative:
|
||||
# (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte
|
||||
|
@ -121,14 +131,19 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
# self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs(
|
||||
# self.etud_moy_ue, self.modimpl_coefs_df
|
||||
# )
|
||||
self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects(
|
||||
self.etud_moy_ue,
|
||||
[ue.ects for ue in self.ues if ue.type != UE_SPORT],
|
||||
formation_id=self.formsemestre.formation_id,
|
||||
skip_empty_ues=sco_preferences.get_preference(
|
||||
"but_moy_skip_empty_ues", self.formsemestre.id
|
||||
),
|
||||
)
|
||||
if self.formsemestre.block_moyenne_generale or self.formsemestre.block_moyennes:
|
||||
self.etud_moy_gen = pd.Series(
|
||||
index=self.etud_moy_ue.index, dtype=float
|
||||
) # NaNs
|
||||
else:
|
||||
self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects(
|
||||
self.etud_moy_ue,
|
||||
ects,
|
||||
formation_id=self.formsemestre.formation_id,
|
||||
skip_empty_ues=sco_preferences.get_preference(
|
||||
"but_moy_skip_empty_ues", self.formsemestre.id
|
||||
),
|
||||
)
|
||||
# --- UE capitalisées
|
||||
self.apply_capitalisation()
|
||||
|
||||
|
@ -145,6 +160,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
# moyenne sur les UE:
|
||||
if len(self.sem_cube[etud_idx, mod_idx]):
|
||||
return np.nanmean(self.sem_cube[etud_idx, mod_idx])
|
||||
# note: si toutes les valeurs sont nan, on va déclencher ici
|
||||
# un RuntimeWarning: Mean of empty slice
|
||||
return np.nan
|
||||
|
||||
def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
|
||||
|
@ -172,9 +189,15 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
modimpls = [
|
||||
modimpl
|
||||
for modimpl in self.formsemestre.modimpls_sorted
|
||||
if modimpl.module.ue.type != UE_SPORT
|
||||
and (coefs[modimpl.id][ue.id] != 0)
|
||||
and self.modimpl_inscr_df[modimpl.id][etudid]
|
||||
if (
|
||||
modimpl.module.ue.type != UE_SPORT
|
||||
and (coefs[modimpl.id][ue.id] != 0)
|
||||
and self.modimpl_inscr_df[modimpl.id][etudid]
|
||||
)
|
||||
or (
|
||||
modimpl.module.module_type == ModuleType.MALUS
|
||||
and modimpl.module.ue_id == ue.id
|
||||
)
|
||||
]
|
||||
if not with_bonus:
|
||||
return [
|
||||
|
@ -204,27 +227,33 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
}
|
||||
self.etuds_parcour_id = etuds_parcour_id
|
||||
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT]
|
||||
# matrice de 1, inscrits par défaut à toutes les UE:
|
||||
ues_inscr_parcours_df = pd.DataFrame(
|
||||
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
|
||||
)
|
||||
if self.formsemestre.formation.referentiel_competence is None:
|
||||
return ues_inscr_parcours_df
|
||||
|
||||
if self.formsemestre.formation.referentiel_competence is None:
|
||||
return pd.DataFrame(
|
||||
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
|
||||
)
|
||||
# matrice de NaN: inscrits par défaut à AUCUNE UE:
|
||||
ues_inscr_parcours_df = pd.DataFrame(
|
||||
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
|
||||
)
|
||||
# Construit pour chaque parcours du référentiel l'ensemble de ses UE
|
||||
# (considère aussi le cas des semestres sans parcours: None)
|
||||
ue_by_parcours = {} # parcours_id : {ue_id:0|1}
|
||||
for parcour in self.formsemestre.formation.referentiel_competence.parcours:
|
||||
ue_by_parcours[parcour.id] = {
|
||||
for (
|
||||
parcour
|
||||
) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]:
|
||||
ue_by_parcours[None if parcour is None else parcour.id] = {
|
||||
ue.id: 1.0
|
||||
for ue in self.formsemestre.formation.query_ues_parcour(
|
||||
parcour
|
||||
).filter_by(semestre_idx=self.formsemestre.semestre_id)
|
||||
}
|
||||
#
|
||||
for etudid in etuds_parcour_id:
|
||||
parcour = etuds_parcour_id[etudid]
|
||||
if parcour is not None:
|
||||
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
|
||||
etuds_parcour_id[etudid]
|
||||
]
|
||||
parcour_id = etuds_parcour_id[etudid]
|
||||
if parcour_id in ue_by_parcours:
|
||||
if ue_by_parcours[parcour_id]:
|
||||
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[parcour_id]
|
||||
return ues_inscr_parcours_df
|
||||
|
||||
def etud_ues_ids(self, etudid: int) -> list[int]:
|
||||
|
@ -233,3 +262,18 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
"""
|
||||
s = self.ues_inscr_parcours_df.loc[etudid]
|
||||
return s.index[s.notna()]
|
||||
|
||||
def etud_has_decision(self, etudid):
|
||||
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
|
||||
prend aussi en compte les autorisations de passage.
|
||||
Sous-classée en BUT pour les RCUEs et années.
|
||||
"""
|
||||
return (
|
||||
super().etud_has_decision(etudid)
|
||||
or ApcValidationAnnee.query.filter_by(
|
||||
formsemestre_id=self.formsemestre.id, etudid=etudid
|
||||
).count()
|
||||
or ApcValidationRCUE.query.filter_by(
|
||||
formsemestre_id=self.formsemestre.id, etudid=etudid
|
||||
).count()
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -22,7 +22,7 @@ from app.models import ScoDocSiteConfig
|
|||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
@ -90,6 +90,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
|
|||
self.modimpl_inscr_df,
|
||||
self.modimpl_coefs,
|
||||
modimpl_standards_mask,
|
||||
block=self.formsemestre.block_moyennes,
|
||||
)
|
||||
# --- Modules de MALUS sur les UEs et la moyenne générale
|
||||
self.malus = moy_ue.compute_malus(
|
||||
|
@ -229,7 +230,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
|
|||
f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id
|
||||
}'\netudid='{etudid}'\nue={ue}"""
|
||||
)
|
||||
etud: Identite = Identite.query.get(etudid)
|
||||
etud = Identite.get_etud(etudid)
|
||||
raise ScoValueError(
|
||||
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
|
||||
impossible à déterminer pour l'étudiant <a href="{
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Résultats semestre: méthodes communes aux formations classiques et APC
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from collections import Counter, defaultdict
|
||||
from collections.abc import Generator
|
||||
from functools import cached_property
|
||||
import numpy as np
|
||||
|
@ -15,7 +15,6 @@ import pandas as pd
|
|||
|
||||
from flask import g, url_for
|
||||
|
||||
from app.auth.models import User
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_cache import ResultatsCache
|
||||
from app.comp.jury import ValidationsSemestre
|
||||
|
@ -23,12 +22,11 @@ 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.scodoc.sco_cache import ResultatsSemestreCache
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
|
||||
from app.scodoc import sco_evaluation_db
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_groups
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
# Il faut bien distinguer
|
||||
|
@ -48,24 +46,27 @@ class ResultatsSemestre(ResultatsCache):
|
|||
_cached_attrs = (
|
||||
"bonus",
|
||||
"bonus_ues",
|
||||
"dispense_ues",
|
||||
"etud_coef_ue_df",
|
||||
"etud_moy_gen_ranks",
|
||||
"etud_moy_gen",
|
||||
"etud_moy_ue",
|
||||
"modimpl_inscr_df",
|
||||
"modimpls_results",
|
||||
"etud_coef_ue_df",
|
||||
"moyennes_matieres",
|
||||
)
|
||||
|
||||
def __init__(self, formsemestre: FormSemestre):
|
||||
super().__init__(formsemestre, ResultatsSemestreCache)
|
||||
# BUT ou standard ? (apc == "approche par compétences")
|
||||
self.is_apc = formsemestre.formation.is_apc()
|
||||
self.is_apc: bool = formsemestre.formation.is_apc()
|
||||
# Attributs "virtuels", définis dans les sous-classes
|
||||
self.bonus: pd.Series = None # virtuel
|
||||
"Bonus sur moy. gen. Series de float, index etudid"
|
||||
self.bonus_ues: pd.DataFrame = None # virtuel
|
||||
"DataFrame de float, index etudid, columns: ue.id"
|
||||
self.dispense_ues: set[tuple[int, int]] = set()
|
||||
"""set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
|
||||
# ResultatsSemestreBUT ou ResultatsSemestreClassic
|
||||
self.etud_moy_ue = {}
|
||||
"etud_moy_ue: DataFrame columns UE, rows etudid"
|
||||
|
@ -83,6 +84,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"""Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""
|
||||
|
||||
self.validations = None
|
||||
self.autorisations_inscription = None
|
||||
self.moyennes_matieres = {}
|
||||
"""Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""
|
||||
|
||||
|
@ -123,7 +125,8 @@ class ResultatsSemestre(ResultatsCache):
|
|||
return [ue.id for ue in self.ues if ue.type != UE_SPORT]
|
||||
|
||||
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
|
||||
"""Liste des UE auxquelles l'étudiant est inscrit."""
|
||||
"""Liste des UE auxquelles l'étudiant est inscrit
|
||||
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
|
||||
return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
|
||||
|
||||
def etud_ects_tot_sem(self, etudid: int) -> float:
|
||||
|
@ -171,13 +174,35 @@ class ResultatsSemestre(ResultatsCache):
|
|||
if m.module.module_type == scu.ModuleType.SAE
|
||||
]
|
||||
|
||||
def get_etudids_attente(self) -> set[int]:
|
||||
"""L'ensemble des etudids ayant au moins une note en ATTente"""
|
||||
return set().union(
|
||||
*[mr.etudids_attente for mr in self.modimpls_results.values()]
|
||||
)
|
||||
|
||||
# --- JURY...
|
||||
def load_validations(self) -> ValidationsSemestre:
|
||||
"""Load validations, set attribute and return value"""
|
||||
def get_formsemestre_validations(self) -> ValidationsSemestre:
|
||||
"""Load validations if not already stored, set attribute and return value"""
|
||||
if not self.validations:
|
||||
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
|
||||
return self.validations
|
||||
|
||||
def get_autorisations_inscription(self) -> dict[int : list[int]]:
|
||||
"""Les autorisations d'inscription venant de ce formsemestre.
|
||||
Lit en base et cache le résultat.
|
||||
Resultat: { etudid : [ indices de semestres ]}
|
||||
Note: les etudids peuvent ne plus être inscrits ici.
|
||||
Seuls ceux avec des autorisations enregistrées sont présents dans le résultat.
|
||||
"""
|
||||
if not self.autorisations_inscription:
|
||||
autorisations = ScolarAutorisationInscription.query.filter_by(
|
||||
origin_formsemestre_id=self.formsemestre.id
|
||||
)
|
||||
self.autorisations_inscription = defaultdict(list)
|
||||
for aut in autorisations:
|
||||
self.autorisations_inscription[aut.etudid].append(aut.semestre_id)
|
||||
return self.autorisations_inscription
|
||||
|
||||
def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
|
||||
"""Liste des UEs du semestre qui doivent être validées
|
||||
|
||||
|
@ -235,8 +260,8 @@ class ResultatsSemestre(ResultatsCache):
|
|||
UE capitalisées.
|
||||
"""
|
||||
# Supposant qu'il y a peu d'UE capitalisées,
|
||||
# on recalcule les moyennes gen des etuds ayant des UE capitalisée.
|
||||
self.load_validations()
|
||||
# on recalcule les moyennes gen des etuds ayant des UEs capitalisées.
|
||||
self.get_formsemestre_validations()
|
||||
ue_capitalisees = self.validations.ue_capitalisees
|
||||
for etudid in ue_capitalisees.index:
|
||||
recompute_mg = False
|
||||
|
@ -274,7 +299,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
|
||||
def get_etud_etat(self, etudid: int) -> str:
|
||||
"Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
|
||||
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
|
||||
ins = self.formsemestre.etuds_inscriptions.get(etudid)
|
||||
if ins is None:
|
||||
return ""
|
||||
return ins.etat
|
||||
|
@ -316,7 +341,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"""L'état de l'UE pour cet étudiant.
|
||||
Result: dict, ou None si l'UE n'est pas dans ce semestre.
|
||||
"""
|
||||
ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ?
|
||||
ue = UniteEns.query.get(ue_id)
|
||||
if ue.type == UE_SPORT:
|
||||
return {
|
||||
"is_capitalized": False,
|
||||
|
@ -363,7 +388,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
|
||||
coef_ue = ue_capitalized.ects
|
||||
if coef_ue is None:
|
||||
orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
|
||||
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])
|
||||
raise ScoValueError(
|
||||
f"""L'UE capitalisée {ue_capitalized.acronyme}
|
||||
du semestre {orig_sem.titre_annee()}
|
||||
|
@ -428,588 +453,3 @@ class ResultatsSemestre(ResultatsCache):
|
|||
# ici si l'étudiant est inscrit dans le semestre courant,
|
||||
# somme des coefs des modules de l'UE auxquels il est inscrit
|
||||
return self.compute_etud_ue_coef(etudid, ue)
|
||||
|
||||
# --- TABLEAU RECAP
|
||||
|
||||
def get_table_recap(
|
||||
self,
|
||||
convert_values=False,
|
||||
include_evaluations=False,
|
||||
mode_jury=False,
|
||||
allow_html=True,
|
||||
):
|
||||
"""Table récap. des résultats.
|
||||
allow_html: si vri, peut-mettre du HTML dans les valeurs
|
||||
|
||||
Result: tuple avec
|
||||
- rows: liste de dicts { column_id : value }
|
||||
- titles: { column_id : title }
|
||||
- columns_ids: (liste des id de colonnes)
|
||||
|
||||
Si convert_values, transforme les notes en chaines ("12.34").
|
||||
Les colonnes générées sont:
|
||||
etudid
|
||||
rang : rang indicatif (basé sur moy gen)
|
||||
moy_gen : moy gen indicative
|
||||
moy_ue_<ue_id>, ..., les moyennes d'UE
|
||||
moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
|
||||
moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE
|
||||
|
||||
On ajoute aussi des attributs:
|
||||
- pour les lignes:
|
||||
_css_row_class (inutilisé pour le monent)
|
||||
_<column_id>_class classe css:
|
||||
- la moyenne générale a la classe col_moy_gen
|
||||
- les colonnes SAE ont la classe col_sae
|
||||
- les colonnes Resources ont la classe col_res
|
||||
- les colonnes d'UE ont la classe col_ue
|
||||
- les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
|
||||
_<column_id>_order : clé de tri
|
||||
"""
|
||||
if convert_values:
|
||||
fmt_note = scu.fmt_note
|
||||
else:
|
||||
fmt_note = lambda x: x
|
||||
|
||||
parcours = self.formsemestre.formation.get_parcours()
|
||||
barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
|
||||
barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
|
||||
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
|
||||
NO_NOTE = "-" # contenu des cellules sans notes
|
||||
rows = []
|
||||
# column_id : title
|
||||
titles = {}
|
||||
# les titres en footer: les mêmes, mais avec des bulles et liens:
|
||||
titles_bot = {}
|
||||
dict_nom_res = {} # cache uid : nomcomplet
|
||||
|
||||
def add_cell(
|
||||
row: dict,
|
||||
col_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
classes: str = "",
|
||||
idx: int = 100,
|
||||
):
|
||||
"Add a row to our table. classes is a list of css class names"
|
||||
row[col_id] = content
|
||||
if classes:
|
||||
row[f"_{col_id}_class"] = classes + f" c{idx}"
|
||||
if not col_id in titles:
|
||||
titles[col_id] = title
|
||||
titles[f"_{col_id}_col_order"] = idx
|
||||
if classes:
|
||||
titles[f"_{col_id}_class"] = classes
|
||||
return idx + 1
|
||||
|
||||
etuds_inscriptions = self.formsemestre.etuds_inscriptions
|
||||
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
|
||||
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
|
||||
modimpl_ids = set() # modimpl effectivement présents dans la table
|
||||
for etudid in etuds_inscriptions:
|
||||
idx = 0 # index de la colonne
|
||||
etud = Identite.query.get(etudid)
|
||||
row = {"etudid": etudid}
|
||||
# --- Codes (seront cachés, mais exportés en excel)
|
||||
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
|
||||
idx = add_cell(
|
||||
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
|
||||
)
|
||||
# --- Rang
|
||||
idx = add_cell(
|
||||
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
|
||||
)
|
||||
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
|
||||
# --- Identité étudiant
|
||||
idx = add_cell(
|
||||
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
|
||||
)
|
||||
idx = add_cell(
|
||||
row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
|
||||
)
|
||||
row["_nom_disp_order"] = etud.sort_key
|
||||
idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
|
||||
idx = add_cell(
|
||||
row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
|
||||
)
|
||||
row["_nom_short_order"] = etud.sort_key
|
||||
row["_nom_short_target"] = url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=self.formsemestre.id,
|
||||
etudid=etudid,
|
||||
)
|
||||
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
|
||||
row["_nom_disp_target"] = row["_nom_short_target"]
|
||||
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
|
||||
|
||||
idx = 30 # début des colonnes de notes
|
||||
# --- Moyenne générale
|
||||
moy_gen = self.etud_moy_gen.get(etudid, False)
|
||||
note_class = ""
|
||||
if moy_gen is False:
|
||||
moy_gen = NO_NOTE
|
||||
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
|
||||
note_class = " moy_ue_warning" # en rouge
|
||||
idx = add_cell(
|
||||
row,
|
||||
"moy_gen",
|
||||
"Moy",
|
||||
fmt_note(moy_gen),
|
||||
"col_moy_gen" + note_class,
|
||||
idx,
|
||||
)
|
||||
titles_bot["_moy_gen_target_attrs"] = (
|
||||
'title="moyenne indicative"' if self.is_apc else ""
|
||||
)
|
||||
# --- Moyenne d'UE
|
||||
nb_ues_validables, nb_ues_warning = 0, 0
|
||||
for ue in ues_sans_bonus:
|
||||
ue_status = self.get_etud_ue_status(etudid, ue.id)
|
||||
if ue_status is not None:
|
||||
col_id = f"moy_ue_{ue.id}"
|
||||
val = ue_status["moy"]
|
||||
note_class = ""
|
||||
if isinstance(val, float):
|
||||
if val < barre_moy:
|
||||
note_class = " moy_inf"
|
||||
elif val >= barre_valid_ue:
|
||||
note_class = " moy_ue_valid"
|
||||
nb_ues_validables += 1
|
||||
if val < barre_warning_ue:
|
||||
note_class = " moy_ue_warning" # notes très basses
|
||||
nb_ues_warning += 1
|
||||
idx = add_cell(
|
||||
row,
|
||||
col_id,
|
||||
ue.acronyme,
|
||||
fmt_note(val),
|
||||
"col_ue" + note_class,
|
||||
idx,
|
||||
)
|
||||
titles_bot[
|
||||
f"_{col_id}_target_attrs"
|
||||
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
|
||||
if mode_jury:
|
||||
# pas d'autre colonnes de résultats
|
||||
continue
|
||||
# Bonus (sport) dans cette UE ?
|
||||
# Le bonus sport appliqué sur cette UE
|
||||
if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
|
||||
val = self.bonus_ues[ue.id][etud.id] or ""
|
||||
val_fmt = val_fmt_html = fmt_note(val)
|
||||
if val:
|
||||
val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
|
||||
idx = add_cell(
|
||||
row,
|
||||
f"bonus_ue_{ue.id}",
|
||||
f"Bonus {ue.acronyme}",
|
||||
val_fmt_html if allow_html else val_fmt,
|
||||
"col_ue_bonus",
|
||||
idx,
|
||||
)
|
||||
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
|
||||
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
|
||||
idx_malus = idx # place pour colonne malus à gauche des modules
|
||||
idx += 1
|
||||
for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
|
||||
if ue_status["is_capitalized"]:
|
||||
val = "-c-"
|
||||
else:
|
||||
modimpl_results = self.modimpls_results.get(modimpl.id)
|
||||
if modimpl_results: # pas bonus
|
||||
if self.is_apc: # BUT
|
||||
moys_vers_ue = modimpl_results.etuds_moy_module.get(
|
||||
ue.id
|
||||
)
|
||||
val = (
|
||||
moys_vers_ue.get(etudid, "?")
|
||||
if moys_vers_ue is not None
|
||||
else ""
|
||||
)
|
||||
else: # classique: Series indépendante de l'UE
|
||||
val = modimpl_results.etuds_moy_module.get(
|
||||
etudid, "?"
|
||||
)
|
||||
else:
|
||||
val = ""
|
||||
|
||||
col_id = (
|
||||
f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
|
||||
)
|
||||
val_fmt = val_fmt_html = fmt_note(val)
|
||||
if convert_values and (
|
||||
modimpl.module.module_type == scu.ModuleType.MALUS
|
||||
):
|
||||
val_fmt_html = (
|
||||
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
|
||||
)
|
||||
idx = add_cell(
|
||||
row,
|
||||
col_id,
|
||||
modimpl.module.code,
|
||||
val_fmt_html,
|
||||
# class col_res mod_ue_123
|
||||
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
|
||||
idx,
|
||||
)
|
||||
row[f"_{col_id}_xls"] = val_fmt
|
||||
if modimpl.module.module_type == scu.ModuleType.MALUS:
|
||||
titles[f"_{col_id}_col_order"] = idx_malus
|
||||
titles_bot[f"_{col_id}_target"] = url_for(
|
||||
"notes.moduleimpl_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
moduleimpl_id=modimpl.id,
|
||||
)
|
||||
nom_resp = dict_nom_res.get(modimpl.responsable_id)
|
||||
if nom_resp is None:
|
||||
user = User.query.get(modimpl.responsable_id)
|
||||
nom_resp = user.get_nomcomplet() if user else ""
|
||||
dict_nom_res[modimpl.responsable_id] = nom_resp
|
||||
titles_bot[
|
||||
f"_{col_id}_target_attrs"
|
||||
] = f""" title="{modimpl.module.titre} ({nom_resp})" """
|
||||
modimpl_ids.add(modimpl.id)
|
||||
nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
|
||||
ue_valid_txt = (
|
||||
ue_valid_txt_html
|
||||
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
|
||||
if nb_ues_warning:
|
||||
ue_valid_txt_html += " " + scu.EMO_WARNING
|
||||
add_cell(
|
||||
row,
|
||||
"ues_validables",
|
||||
"UEs",
|
||||
ue_valid_txt_html,
|
||||
"col_ue col_ues_validables",
|
||||
29, # juste avant moy. gen.
|
||||
)
|
||||
row["_ues_validables_xls"] = ue_valid_txt
|
||||
if nb_ues_warning:
|
||||
row["_ues_validables_class"] += " moy_ue_warning"
|
||||
elif nb_ues_validables < len(ues_sans_bonus):
|
||||
row["_ues_validables_class"] += " moy_inf"
|
||||
row["_ues_validables_order"] = nb_ues_validables # pour tri
|
||||
if mode_jury and self.validations:
|
||||
if self.is_apc:
|
||||
# formations BUT: pas de code semestre, concatene ceux des UE
|
||||
dec_ues = self.validations.decisions_jury_ues.get(etudid)
|
||||
if dec_ues:
|
||||
jury_code_sem = ",".join(
|
||||
[dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
|
||||
)
|
||||
else:
|
||||
jury_code_sem = ""
|
||||
else:
|
||||
# formations classiqes: code semestre
|
||||
dec_sem = self.validations.decisions_jury.get(etudid)
|
||||
jury_code_sem = dec_sem["code"] if dec_sem else ""
|
||||
idx = add_cell(
|
||||
row,
|
||||
"jury_code_sem",
|
||||
"Jury",
|
||||
jury_code_sem,
|
||||
"jury_code_sem",
|
||||
1000,
|
||||
)
|
||||
idx = add_cell(
|
||||
row,
|
||||
"jury_link",
|
||||
"",
|
||||
f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
|
||||
)
|
||||
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
|
||||
"col_jury_link",
|
||||
idx,
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
self.recap_add_partitions(rows, titles)
|
||||
self._recap_add_admissions(rows, titles)
|
||||
|
||||
# tri par rang croissant
|
||||
rows.sort(key=lambda e: e["_rang_order"])
|
||||
|
||||
# INFOS POUR FOOTER
|
||||
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
|
||||
if include_evaluations:
|
||||
self._recap_add_evaluations(rows, titles, bottom_infos)
|
||||
|
||||
# Ajoute style "col_empty" aux colonnes de modules vides
|
||||
for col_id in titles:
|
||||
c_class = f"_{col_id}_class"
|
||||
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
|
||||
for row in rows:
|
||||
row[c_class] = row.get(c_class, "") + " col_empty"
|
||||
titles[c_class] += " col_empty"
|
||||
for row in bottom_infos.values():
|
||||
row[c_class] = row.get(c_class, "") + " col_empty"
|
||||
|
||||
# --- TABLE FOOTER: ECTS, moyennes, min, max...
|
||||
footer_rows = []
|
||||
for (bottom_line, row) in bottom_infos.items():
|
||||
# Cases vides à styler:
|
||||
row["moy_gen"] = row.get("moy_gen", "")
|
||||
row["_moy_gen_class"] = "col_moy_gen"
|
||||
# titre de la ligne:
|
||||
row["prenom"] = row["nom_short"] = (
|
||||
row.get("_title", "") or bottom_line.capitalize()
|
||||
)
|
||||
row["_tr_class"] = bottom_line.lower() + (
|
||||
(" " + row["_tr_class"]) if "_tr_class" in row else ""
|
||||
)
|
||||
footer_rows.append(row)
|
||||
titles_bot.update(titles)
|
||||
footer_rows.append(titles_bot)
|
||||
column_ids = [title for title in titles if not title.startswith("_")]
|
||||
column_ids.sort(
|
||||
key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
|
||||
)
|
||||
return (rows, footer_rows, titles, column_ids)
|
||||
|
||||
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
|
||||
"""Les informations à mettre en bas de la table: min, max, moy, ECTS"""
|
||||
row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
|
||||
{"_tr_class": "bottom_info", "_title": "Min."},
|
||||
{"_tr_class": "bottom_info"},
|
||||
{"_tr_class": "bottom_info"},
|
||||
{"_tr_class": "bottom_info"},
|
||||
{"_tr_class": "bottom_info"},
|
||||
{"_tr_class": "bottom_info", "_title": "Code Apogée"},
|
||||
)
|
||||
# --- ECTS
|
||||
for ue in ues:
|
||||
colid = f"moy_ue_{ue.id}"
|
||||
row_ects[colid] = ue.ects
|
||||
row_ects[f"_{colid}_class"] = "col_ue"
|
||||
# style cases vides pour borders verticales
|
||||
row_coef[colid] = ""
|
||||
row_coef[f"_{colid}_class"] = "col_ue"
|
||||
# row_apo[colid] = ue.code_apogee or ""
|
||||
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
|
||||
row_ects["_moy_gen_class"] = "col_moy_gen"
|
||||
|
||||
# --- MIN, MAX, MOY, APO
|
||||
|
||||
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
|
||||
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
|
||||
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
|
||||
for ue in ues:
|
||||
colid = f"moy_ue_{ue.id}"
|
||||
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
|
||||
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
|
||||
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
|
||||
row_min[f"_{colid}_class"] = "col_ue"
|
||||
row_max[f"_{colid}_class"] = "col_ue"
|
||||
row_moy[f"_{colid}_class"] = "col_ue"
|
||||
row_apo[colid] = ue.code_apogee or ""
|
||||
|
||||
for modimpl in self.formsemestre.modimpls_sorted:
|
||||
if modimpl.id in modimpl_ids:
|
||||
colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
|
||||
if self.is_apc:
|
||||
coef = self.modimpl_coefs_df[modimpl.id][ue.id]
|
||||
else:
|
||||
coef = modimpl.module.coefficient or 0
|
||||
row_coef[colid] = fmt_note(coef)
|
||||
notes = self.modimpl_notes(modimpl.id, ue.id)
|
||||
if np.isnan(notes).all():
|
||||
# aucune note valide
|
||||
row_min[colid] = np.nan
|
||||
row_max[colid] = np.nan
|
||||
moy = np.nan
|
||||
else:
|
||||
row_min[colid] = fmt_note(np.nanmin(notes))
|
||||
row_max[colid] = fmt_note(np.nanmax(notes))
|
||||
moy = np.nanmean(notes)
|
||||
row_moy[colid] = fmt_note(moy)
|
||||
if np.isnan(moy):
|
||||
# aucune note dans ce module
|
||||
row_moy[f"_{colid}_class"] = "col_empty"
|
||||
row_apo[colid] = modimpl.module.code_apogee or ""
|
||||
|
||||
return { # { key : row } avec key = min, max, moy, coef
|
||||
"min": row_min,
|
||||
"max": row_max,
|
||||
"moy": row_moy,
|
||||
"coef": row_coef,
|
||||
"ects": row_ects,
|
||||
"apo": row_apo,
|
||||
}
|
||||
|
||||
def _recap_etud_groups_infos(
|
||||
self, etudid: int, row: dict, titles: dict
|
||||
): # XXX non utilisé
|
||||
"""Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
|
||||
# dec = self.get_etud_decision_sem(etudid)
|
||||
# if dec:
|
||||
# codes_nb[dec["code"]] += 1
|
||||
row_class = ""
|
||||
etud_etat = self.get_etud_etat(etudid)
|
||||
if etud_etat == DEM:
|
||||
gr_name = "Dém."
|
||||
row_class = "dem"
|
||||
elif etud_etat == DEF:
|
||||
gr_name = "Déf."
|
||||
row_class = "def"
|
||||
else:
|
||||
# XXX probablement à revoir pour utiliser données cachées,
|
||||
# via get_etud_groups_in_partition ou autre
|
||||
group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
|
||||
gr_name = group["group_name"] or ""
|
||||
row["group"] = gr_name
|
||||
row["_group_class"] = "group"
|
||||
if row_class:
|
||||
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
|
||||
titles["group"] = "Gr"
|
||||
|
||||
def _recap_add_admissions(self, rows: list[dict], titles: dict):
|
||||
"""Ajoute les colonnes "admission"
|
||||
rows est une liste de dict avec une clé "etudid"
|
||||
Les colonnes ont la classe css "admission"
|
||||
"""
|
||||
fields = {
|
||||
"bac": "Bac",
|
||||
"specialite": "Spécialité",
|
||||
"type_admission": "Type Adm.",
|
||||
"classement": "Rg. Adm.",
|
||||
}
|
||||
first = True
|
||||
for i, cid in enumerate(fields):
|
||||
titles[f"_{cid}_col_order"] = 10000 + i # tout à droite
|
||||
if first:
|
||||
titles[f"_{cid}_class"] = "admission admission_first"
|
||||
first = False
|
||||
else:
|
||||
titles[f"_{cid}_class"] = "admission"
|
||||
titles.update(fields)
|
||||
for row in rows:
|
||||
etud = Identite.query.get(row["etudid"])
|
||||
admission = etud.admission.first()
|
||||
first = True
|
||||
for cid in fields:
|
||||
row[cid] = getattr(admission, cid) or ""
|
||||
if first:
|
||||
row[f"_{cid}_class"] = "admission admission_first"
|
||||
first = False
|
||||
else:
|
||||
row[f"_{cid}_class"] = "admission"
|
||||
|
||||
def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None):
|
||||
"""Ajoute les colonnes indiquant les groupes
|
||||
rows est une liste de dict avec une clé "etudid"
|
||||
Les colonnes ont la classe css "partition"
|
||||
"""
|
||||
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
|
||||
self.formsemestre.id
|
||||
)
|
||||
first_partition = True
|
||||
col_order = 10 if col_idx is None else col_idx
|
||||
for partition in partitions:
|
||||
cid = f"part_{partition['partition_id']}"
|
||||
rg_cid = cid + "_rg" # rang dans la partition
|
||||
titles[cid] = partition["partition_name"]
|
||||
if first_partition:
|
||||
klass = "partition"
|
||||
else:
|
||||
klass = "partition partition_aux"
|
||||
titles[f"_{cid}_class"] = klass
|
||||
titles[f"_{cid}_col_order"] = col_order
|
||||
titles[f"_{rg_cid}_col_order"] = col_order + 1
|
||||
col_order += 2
|
||||
if partition["bul_show_rank"]:
|
||||
titles[rg_cid] = f"Rg {partition['partition_name']}"
|
||||
titles[f"_{rg_cid}_class"] = "partition_rangs"
|
||||
partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
|
||||
for row in rows:
|
||||
group = None # group (dict) de l'étudiant dans cette partition
|
||||
# dans NotesTableCompat, à revoir
|
||||
etud_etat = self.get_etud_etat(row["etudid"])
|
||||
if etud_etat == "D":
|
||||
gr_name = "Dém."
|
||||
row["_tr_class"] = "dem"
|
||||
elif etud_etat == DEF:
|
||||
gr_name = "Déf."
|
||||
row["_tr_class"] = "def"
|
||||
else:
|
||||
group = partition_etud_groups.get(row["etudid"])
|
||||
gr_name = group["group_name"] if group else ""
|
||||
if gr_name:
|
||||
row[cid] = gr_name
|
||||
row[f"_{cid}_class"] = klass
|
||||
# Rangs dans groupe
|
||||
if (
|
||||
partition["bul_show_rank"]
|
||||
and (group is not None)
|
||||
and (group["id"] in self.moy_gen_rangs_by_group)
|
||||
):
|
||||
rang = self.moy_gen_rangs_by_group[group["id"]][0]
|
||||
row[rg_cid] = rang.get(row["etudid"], "")
|
||||
|
||||
first_partition = False
|
||||
|
||||
def _recap_add_evaluations(
|
||||
self, rows: list[dict], titles: dict, bottom_infos: dict
|
||||
):
|
||||
"""Ajoute les colonnes avec les notes aux évaluations
|
||||
rows est une liste de dict avec une clé "etudid"
|
||||
Les colonnes ont la classe css "evaluation"
|
||||
"""
|
||||
# nouvelle ligne pour description évaluations:
|
||||
bottom_infos["descr_evaluation"] = {
|
||||
"_tr_class": "bottom_info",
|
||||
"_title": "Description évaluation",
|
||||
}
|
||||
first_eval = True
|
||||
index_col = 9000 # à droite
|
||||
for modimpl in self.formsemestre.modimpls_sorted:
|
||||
evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
|
||||
eval_index = len(evals) - 1
|
||||
inscrits = {i.etudid for i in modimpl.inscriptions}
|
||||
first_eval_of_mod = True
|
||||
for e in evals:
|
||||
cid = f"eval_{e.id}"
|
||||
titles[
|
||||
cid
|
||||
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
|
||||
klass = "evaluation"
|
||||
if first_eval:
|
||||
klass += " first"
|
||||
elif first_eval_of_mod:
|
||||
klass += " first_of_mod"
|
||||
titles[f"_{cid}_class"] = klass
|
||||
first_eval_of_mod = first_eval = False
|
||||
titles[f"_{cid}_col_order"] = index_col
|
||||
index_col += 1
|
||||
eval_index -= 1
|
||||
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
|
||||
e.evaluation_id
|
||||
)
|
||||
for row in rows:
|
||||
etudid = row["etudid"]
|
||||
if etudid in inscrits:
|
||||
if etudid in notes_db:
|
||||
val = notes_db[etudid]["value"]
|
||||
else:
|
||||
# Note manquante mais prise en compte immédiate: affiche ATT
|
||||
val = scu.NOTES_ATTENTE
|
||||
row[cid] = scu.fmt_note(val)
|
||||
row[f"_{cid}_class"] = klass + {
|
||||
"ABS": " abs",
|
||||
"ATT": " att",
|
||||
"EXC": " exc",
|
||||
}.get(row[cid], "")
|
||||
else:
|
||||
row[cid] = "ni"
|
||||
row[f"_{cid}_class"] = klass + " non_inscrit"
|
||||
|
||||
bottom_infos["coef"][cid] = e.coefficient
|
||||
bottom_infos["min"][cid] = "0"
|
||||
bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
|
||||
bottom_infos["descr_evaluation"][cid] = e.description or ""
|
||||
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
|
||||
"notes.evaluation_listenotes",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
evaluation_id=e.id,
|
||||
)
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Classe résultats pour compatibilité avec le code ScoDoc 7
|
||||
"""
|
||||
from functools import cached_property
|
||||
import pandas as pd
|
||||
|
||||
from flask import flash, g, Markup, url_for
|
||||
|
||||
|
@ -14,11 +15,8 @@ from app import log
|
|||
from app.comp import moy_sem
|
||||
from app.comp.aux_stats import StatsMoyenne
|
||||
from app.comp.res_common import ResultatsSemestre
|
||||
from app.comp import res_sem
|
||||
from app.models import FormSemestre
|
||||
from app.models import Identite
|
||||
from app.models import ModuleImpl
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
|
||||
from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription
|
||||
from app.scodoc.codes_cursus import UE_SPORT, DEF
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
# Pour raccorder le code des anciens codes qui attendent une NoteTable
|
||||
|
@ -26,7 +24,7 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
"""Implementation partielle de NotesTable
|
||||
|
||||
Les méthodes définies dans cette classe sont là
|
||||
pour conserver la compatibilité abvec les codes anciens et
|
||||
pour conserver la compatibilité avec les codes anciens et
|
||||
il n'est pas recommandé de les utiliser dans de nouveaux
|
||||
développements (API malcommode et peu efficace).
|
||||
"""
|
||||
|
@ -53,7 +51,7 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
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_parcours()
|
||||
self.parcours = self.formsemestre.formation.get_cursus()
|
||||
self._modimpls_dict_by_ue = {} # local cache
|
||||
|
||||
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
|
||||
|
@ -104,10 +102,9 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
"""Stats (moy/min/max) sur la moyenne générale"""
|
||||
return StatsMoyenne(self.etud_moy_gen)
|
||||
|
||||
def get_ues_stat_dict(
|
||||
self, filter_sport=False, check_apc_ects=True
|
||||
) -> list[dict]: # was get_ues()
|
||||
"""Liste des UEs, ordonnée par numero.
|
||||
def get_ues_stat_dict(self, filter_sport=False, check_apc_ects=True) -> list[dict]:
|
||||
"""Liste des UEs de toutes les UEs du semestre (tous parcours),
|
||||
ordonnée par numero.
|
||||
Si filter_sport, retire les UE de type SPORT.
|
||||
Résultat: liste de dicts { champs UE U stats moyenne UE }
|
||||
"""
|
||||
|
@ -168,15 +165,24 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
moy_gen_rangs_by_group[group_id]
|
||||
ue_rangs_by_group[group_id]
|
||||
"""
|
||||
mask_inscr = pd.Series(
|
||||
[
|
||||
self.formsemestre.etuds_inscriptions[etudid].etat == scu.INSCRIT
|
||||
for etudid in self.etud_moy_gen.index
|
||||
],
|
||||
dtype=float,
|
||||
index=self.etud_moy_gen.index,
|
||||
)
|
||||
etud_moy_gen_dem_zero = self.etud_moy_gen * mask_inscr
|
||||
(
|
||||
self.etud_moy_gen_ranks,
|
||||
self.etud_moy_gen_ranks_int,
|
||||
) = moy_sem.comp_ranks_series(self.etud_moy_gen)
|
||||
) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero)
|
||||
ues = self.formsemestre.query_ues()
|
||||
for ue in ues:
|
||||
moy_ue = self.etud_moy_ue[ue.id]
|
||||
self.ue_rangs[ue.id] = (
|
||||
moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine
|
||||
moy_sem.comp_ranks_series(moy_ue * mask_inscr)[0], # juste en chaine
|
||||
int(moy_ue.count()),
|
||||
)
|
||||
# .count() -> nb of non NaN values
|
||||
|
@ -196,7 +202,7 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
)
|
||||
# list() car pandas veut une sequence pour take()
|
||||
# Rangs / moyenne générale:
|
||||
group_moys_gen = self.etud_moy_gen[group_members]
|
||||
group_moys_gen = etud_moy_gen_dem_zero[group_members]
|
||||
self.moy_gen_rangs_by_group[group.id] = moy_sem.comp_ranks_series(
|
||||
group_moys_gen
|
||||
)
|
||||
|
@ -205,7 +211,7 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
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)
|
||||
] = 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.
|
||||
|
@ -272,10 +278,19 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
return True
|
||||
|
||||
def etud_has_decision(self, etudid):
|
||||
"""True s'il y a une décision de jury pour cet étudiant"""
|
||||
return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid)
|
||||
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
|
||||
prend aussi en compte les autorisations de passage.
|
||||
Sous-classée en BUT pour les RCUEs et années.
|
||||
"""
|
||||
return (
|
||||
self.get_etud_decisions_ue(etudid)
|
||||
or self.get_etud_decision_sem(etudid)
|
||||
or ScolarAutorisationInscription.query.filter_by(
|
||||
origin_formsemestre_id=self.formsemestre.id, etudid=etudid
|
||||
).count()
|
||||
)
|
||||
|
||||
def get_etud_decision_ues(self, etudid: int) -> dict:
|
||||
def get_etud_decisions_ue(self, etudid: int) -> dict:
|
||||
"""Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu.
|
||||
Ne tient pas compte des UE capitalisées.
|
||||
{ ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : "d/m/y", 'ects' : x }
|
||||
|
@ -284,16 +299,16 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
if self.get_etud_etat(etudid) == DEF:
|
||||
return {}
|
||||
else:
|
||||
validations = self.load_validations()
|
||||
validations = self.get_formsemestre_validations()
|
||||
return validations.decisions_jury_ues.get(etudid, None)
|
||||
|
||||
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> 0:
|
||||
"""Le total des ECTS validés (et enregistrés) par l'étudiant dans ce semestre.
|
||||
NB: avant jury, rien d'enregistré, donc zéro ECTS.
|
||||
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decision_ues()
|
||||
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decisions_ue()
|
||||
"""
|
||||
if decisions_ues is False:
|
||||
decisions_ues = self.get_etud_decision_ues(etudid)
|
||||
decisions_ues = self.get_etud_decisions_ue(etudid)
|
||||
if not decisions_ues:
|
||||
return 0.0
|
||||
return sum([d.get("ects", 0.0) for d in decisions_ues.values()])
|
||||
|
@ -311,7 +326,7 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
"compense_formsemestre_id": None,
|
||||
}
|
||||
else:
|
||||
validations = self.load_validations()
|
||||
validations = self.get_formsemestre_validations()
|
||||
return validations.decisions_jury.get(etudid, None)
|
||||
|
||||
def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import flask_login
|
|||
import app
|
||||
from app.auth.models import User
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
class ZUser(object):
|
||||
|
@ -95,7 +96,7 @@ def permission_required(permission):
|
|||
return decorator
|
||||
|
||||
|
||||
def permission_required_compat_scodoc7(permission):
|
||||
def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER
|
||||
"""Décorateur pour les fonctions utilisées comme API dans ScoDoc 7
|
||||
Comme @permission_required mais autorise de passer directement
|
||||
les informations d'auth en paramètres:
|
||||
|
@ -117,6 +118,10 @@ def permission_required_compat_scodoc7(permission):
|
|||
else:
|
||||
abort(405) # method not allowed
|
||||
if user_name and user_password:
|
||||
# Ancienne API: va être supprimée courant mars 2023
|
||||
current_app.logger.warning(
|
||||
"using DEPRECATED ScoDoc7 authentication method !"
|
||||
)
|
||||
u = User.query.filter_by(user_name=user_name).first()
|
||||
if u and u.check_password(user_password):
|
||||
auth_ok = True
|
||||
|
@ -180,19 +185,24 @@ def scodoc7func(func):
|
|||
else:
|
||||
arg_names = argspec.args
|
||||
for arg_name in arg_names: # pour chaque arg de la fonction vue
|
||||
if arg_name == "REQUEST": # ne devrait plus arriver !
|
||||
# debug check, TODO remove after tests
|
||||
raise ValueError("invalid REQUEST parameter !")
|
||||
else:
|
||||
# peut produire une KeyError s'il manque un argument attendu:
|
||||
v = req_args[arg_name]
|
||||
# try to convert all arguments to INTEGERS
|
||||
# necessary for db ids and boolean values
|
||||
try:
|
||||
v = int(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
pos_arg_values.append(v)
|
||||
# peut produire une KeyError s'il manque un argument attendu:
|
||||
v = req_args[arg_name]
|
||||
# try to convert all arguments to INTEGERS
|
||||
# necessary for db ids and boolean values
|
||||
try:
|
||||
v = int(v) if v else v
|
||||
except (ValueError, TypeError) as exc:
|
||||
if arg_name in {
|
||||
"etudid",
|
||||
"formation_id",
|
||||
"formsemestre_id",
|
||||
"module_id",
|
||||
"moduleimpl_id",
|
||||
"partition_id",
|
||||
"ue_id",
|
||||
}:
|
||||
raise ScoValueError("page introuvable (id invalide)") from exc
|
||||
pos_arg_values.append(v)
|
||||
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
|
||||
# current_app.logger.info("req_args=%s" % req_args)
|
||||
# Add keyword arguments
|
||||
|
|
34
app/email.py
34
app/email.py
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -11,6 +11,8 @@ from flask import current_app, g
|
|||
from flask_mail import Message
|
||||
|
||||
from app import mail
|
||||
from app.models.departements import Departement
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import sco_preferences
|
||||
|
||||
|
||||
|
@ -56,6 +58,7 @@ def send_message(msg: Message):
|
|||
In mail debug mode, addresses are discarded and all mails are sent to the
|
||||
specified debugging address.
|
||||
"""
|
||||
email_test_mode_address = False
|
||||
if hasattr(g, "scodoc_dept"):
|
||||
# on est dans un département, on peut accéder aux préférences
|
||||
email_test_mode_address = sco_preferences.get_preference(
|
||||
|
@ -81,6 +84,35 @@ Adresses d'origine:
|
|||
+ msg.body
|
||||
)
|
||||
|
||||
current_app.logger.info(
|
||||
f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
|
||||
from sender {msg.sender}
|
||||
"""
|
||||
)
|
||||
Thread(
|
||||
target=send_async_email, args=(current_app._get_current_object(), msg)
|
||||
).start()
|
||||
|
||||
|
||||
def get_from_addr(dept_acronym: str = None):
|
||||
"""L'adresse "from" à utiliser pour envoyer un mail
|
||||
|
||||
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
|
||||
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
|
||||
Sinon, utilise le paramètre global `email_from_addr`.
|
||||
Sinon, la variable de config `SCODOC_MAIL_FROM`.
|
||||
"""
|
||||
dept_acronym = dept_acronym or getattr(g, "scodoc_dept", None)
|
||||
if dept_acronym:
|
||||
dept = Departement.query.filter_by(acronym=dept_acronym).first()
|
||||
if dept:
|
||||
from_addr = (
|
||||
sco_preferences.get_preference("email_from_addr", dept_id=dept.id) or ""
|
||||
).strip()
|
||||
if from_addr:
|
||||
return from_addr
|
||||
return (
|
||||
ScoDocSiteConfig.get("email_from_addr")
|
||||
or current_app.config["SCODOC_MAIL_FROM"]
|
||||
or "none"
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -216,7 +216,7 @@ def send_email_notifications_entreprise(subject: str, entreprise: Entreprise):
|
|||
txt = "\n".join(txt)
|
||||
email.send_email(
|
||||
subject,
|
||||
sco_preferences.get_preference("email_from_addr"),
|
||||
email.get_from_addr(),
|
||||
[EntreprisePreferences.get_email_notifications],
|
||||
txt,
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -89,7 +89,7 @@ def index():
|
|||
visible=True, association=True, siret_provisoire=True
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/entreprises.html",
|
||||
"entreprises/entreprises.j2",
|
||||
title="Entreprises",
|
||||
entreprises=entreprises,
|
||||
logs=logs,
|
||||
|
@ -109,7 +109,7 @@ def logs():
|
|||
EntrepriseHistorique.date.desc()
|
||||
).paginate(page=page, per_page=20)
|
||||
return render_template(
|
||||
"entreprises/logs.html",
|
||||
"entreprises/logs.j2",
|
||||
title="Logs",
|
||||
logs=logs,
|
||||
)
|
||||
|
@ -134,7 +134,7 @@ def correspondants():
|
|||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/correspondants.html",
|
||||
"entreprises/correspondants.j2",
|
||||
title="Correspondants",
|
||||
correspondants=correspondants,
|
||||
logs=logs,
|
||||
|
@ -149,7 +149,7 @@ def validation():
|
|||
"""
|
||||
entreprises = Entreprise.query.filter_by(visible=False).all()
|
||||
return render_template(
|
||||
"entreprises/entreprises_validation.html",
|
||||
"entreprises/entreprises_validation.j2",
|
||||
title="Validation entreprises",
|
||||
entreprises=entreprises,
|
||||
)
|
||||
|
@ -167,7 +167,7 @@ def fiche_entreprise_validation(entreprise_id):
|
|||
description=f"fiche entreprise (validation) {entreprise_id} inconnue"
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/fiche_entreprise_validation.html",
|
||||
"entreprises/fiche_entreprise_validation.j2",
|
||||
title="Validation fiche entreprise",
|
||||
entreprise=entreprise,
|
||||
)
|
||||
|
@ -205,7 +205,7 @@ def validate_entreprise(entreprise_id):
|
|||
flash("L'entreprise a été validé et ajouté à la liste.")
|
||||
return redirect(url_for("entreprises.validation"))
|
||||
return render_template(
|
||||
"entreprises/form_validate_confirmation.html",
|
||||
"entreprises/form_validate_confirmation.j2",
|
||||
title="Validation entreprise",
|
||||
form=form,
|
||||
)
|
||||
|
@ -242,7 +242,7 @@ def delete_validation_entreprise(entreprise_id):
|
|||
flash("L'entreprise a été supprimé de la liste des entreprise à valider.")
|
||||
return redirect(url_for("entreprises.validation"))
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supression entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -282,7 +282,7 @@ def offres_recues():
|
|||
files.append(file)
|
||||
offres_recues_with_files.append([envoi_offre, offre, files, correspondant])
|
||||
return render_template(
|
||||
"entreprises/offres_recues.html",
|
||||
"entreprises/offres_recues.j2",
|
||||
title="Offres reçues",
|
||||
offres_recues=offres_recues_with_files,
|
||||
)
|
||||
|
@ -321,7 +321,7 @@ def preferences():
|
|||
form.mail_entreprise.data = EntreprisePreferences.get_email_notifications()
|
||||
form.check_siret.data = int(EntreprisePreferences.get_check_siret())
|
||||
return render_template(
|
||||
"entreprises/preferences.html",
|
||||
"entreprises/preferences.j2",
|
||||
title="Préférences",
|
||||
form=form,
|
||||
)
|
||||
|
@ -357,7 +357,7 @@ def add_entreprise():
|
|||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
"entreprises/form_ajout_entreprise.html",
|
||||
"entreprises/form_ajout_entreprise.j2",
|
||||
title="Ajout entreprise avec correspondant",
|
||||
form=form,
|
||||
)
|
||||
|
@ -408,7 +408,7 @@ def add_entreprise():
|
|||
flash("L'entreprise a été ajouté à la liste pour la validation.")
|
||||
return redirect(url_for("entreprises.index"))
|
||||
return render_template(
|
||||
"entreprises/form_ajout_entreprise.html",
|
||||
"entreprises/form_ajout_entreprise.j2",
|
||||
title="Ajout entreprise avec correspondant",
|
||||
form=form,
|
||||
)
|
||||
|
@ -446,7 +446,7 @@ def fiche_entreprise(entreprise_id):
|
|||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/fiche_entreprise.html",
|
||||
"entreprises/fiche_entreprise.j2",
|
||||
title="Fiche entreprise",
|
||||
entreprise=entreprise,
|
||||
offres=offres_with_files,
|
||||
|
@ -472,7 +472,7 @@ def logs_entreprise(entreprise_id):
|
|||
.paginate(page=page, per_page=20)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/logs_entreprise.html",
|
||||
"entreprises/logs_entreprise.j2",
|
||||
title="Logs",
|
||||
logs=logs,
|
||||
entreprise=entreprise,
|
||||
|
@ -490,7 +490,7 @@ def offres_expirees(entreprise_id):
|
|||
).first_or_404(description=f"fiche entreprise {entreprise_id} inconnue")
|
||||
offres_with_files = are.get_offres_expirees_with_files(entreprise.offres)
|
||||
return render_template(
|
||||
"entreprises/offres_expirees.html",
|
||||
"entreprises/offres_expirees.j2",
|
||||
title="Offres expirées",
|
||||
entreprise=entreprise,
|
||||
offres_expirees=offres_with_files,
|
||||
|
@ -574,7 +574,7 @@ def edit_entreprise(entreprise_id):
|
|||
form.pays.data = entreprise.pays
|
||||
form.association.data = entreprise.association
|
||||
return render_template(
|
||||
"entreprises/form_modification_entreprise.html",
|
||||
"entreprises/form_modification_entreprise.j2",
|
||||
title="Modification entreprise",
|
||||
form=form,
|
||||
)
|
||||
|
@ -610,7 +610,7 @@ def fiche_entreprise_desactiver(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Désactiver entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
|
||||
|
@ -646,7 +646,7 @@ def fiche_entreprise_activer(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Activer entreprise",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
|
||||
|
@ -692,7 +692,7 @@ def add_taxe_apprentissage(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Ajout taxe apprentissage",
|
||||
form=form,
|
||||
)
|
||||
|
@ -735,7 +735,7 @@ def edit_taxe_apprentissage(entreprise_id, taxe_id):
|
|||
form.montant.data = taxe.montant
|
||||
form.notes.data = taxe.notes
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Modification taxe apprentissage",
|
||||
form=form,
|
||||
)
|
||||
|
@ -775,7 +775,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supprimer taxe apprentissage",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -845,7 +845,7 @@ def add_offre(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Ajout offre",
|
||||
form=form,
|
||||
)
|
||||
|
@ -921,7 +921,7 @@ def edit_offre(entreprise_id, offre_id):
|
|||
form.expiration_date.data = offre.expiration_date
|
||||
form.depts.data = offre_depts_list
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Modification offre",
|
||||
form=form,
|
||||
)
|
||||
|
@ -971,7 +971,7 @@ def delete_offre(entreprise_id, offre_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supression offre",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -1047,7 +1047,7 @@ def add_site(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Ajout site",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1098,7 +1098,7 @@ def edit_site(entreprise_id, site_id):
|
|||
form.ville.data = site.ville
|
||||
form.pays.data = site.pays
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Modification site",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1154,7 +1154,7 @@ def add_correspondant(entreprise_id, site_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=site.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_ajout_correspondants.html",
|
||||
"entreprises/form_ajout_correspondants.j2",
|
||||
title="Ajout correspondant",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1234,7 +1234,7 @@ def edit_correspondant(entreprise_id, site_id, correspondant_id):
|
|||
form.origine.data = correspondant.origine
|
||||
form.notes.data = correspondant.notes
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Modification correspondant",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1290,7 +1290,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
|
|||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supression correspondant",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -1308,7 +1308,7 @@ def contacts(entreprise_id):
|
|||
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
|
||||
contacts = EntrepriseContact.query.filter_by(entreprise=entreprise.id).all()
|
||||
return render_template(
|
||||
"entreprises/contacts.html",
|
||||
"entreprises/contacts.j2",
|
||||
title="Liste des contacts",
|
||||
contacts=contacts,
|
||||
entreprise=entreprise,
|
||||
|
@ -1365,7 +1365,7 @@ def add_contact(entreprise_id):
|
|||
db.session.commit()
|
||||
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise.id))
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Ajout contact",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1421,7 +1421,7 @@ def edit_contact(entreprise_id, contact_id):
|
|||
)
|
||||
form.notes.data = contact.notes
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Modification contact",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1459,7 +1459,7 @@ def delete_contact(entreprise_id, contact_id):
|
|||
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supression contact",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -1525,7 +1525,7 @@ def add_stage_apprentissage(entreprise_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_ajout_stage_apprentissage.html",
|
||||
"entreprises/form_ajout_stage_apprentissage.j2",
|
||||
title="Ajout stage / apprentissage",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1599,7 +1599,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
|
|||
form.date_fin.data = stage_apprentissage.date_fin
|
||||
form.notes.data = stage_apprentissage.notes
|
||||
return render_template(
|
||||
"entreprises/form_ajout_stage_apprentissage.html",
|
||||
"entreprises/form_ajout_stage_apprentissage.j2",
|
||||
title="Modification stage / apprentissage",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1640,7 +1640,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
|
|||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Supression stage/apprentissage",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -1690,7 +1690,7 @@ def envoyer_offre(entreprise_id, offre_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_envoi_offre.html",
|
||||
"entreprises/form_envoi_offre.j2",
|
||||
title="Envoyer une offre",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1816,7 +1816,7 @@ def import_donnees():
|
|||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
"entreprises/import_donnees.html",
|
||||
"entreprises/import_donnees.j2",
|
||||
title="Importation données",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1845,7 +1845,7 @@ def import_donnees():
|
|||
db.session.commit()
|
||||
flash(f"Importation réussie")
|
||||
return render_template(
|
||||
"entreprises/import_donnees.html",
|
||||
"entreprises/import_donnees.j2",
|
||||
title="Importation données",
|
||||
form=form,
|
||||
entreprises_import=entreprises_import,
|
||||
|
@ -1853,7 +1853,7 @@ def import_donnees():
|
|||
correspondants_import=correspondants,
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/import_donnees.html", title="Importation données", form=form
|
||||
"entreprises/import_donnees.j2", title="Importation données", form=form
|
||||
)
|
||||
|
||||
|
||||
|
@ -1927,7 +1927,7 @@ def add_offre_file(entreprise_id, offre_id):
|
|||
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form.html",
|
||||
"entreprises/form.j2",
|
||||
title="Ajout fichier à une offre",
|
||||
form=form,
|
||||
)
|
||||
|
@ -1969,7 +1969,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
|
|||
)
|
||||
)
|
||||
return render_template(
|
||||
"entreprises/form_confirmation.html",
|
||||
"entreprises/form_confirmation.j2",
|
||||
title="Suppression fichier d'une offre",
|
||||
form=form,
|
||||
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
|
||||
|
@ -1981,4 +1981,4 @@ def not_found_error_handler(e):
|
|||
"""
|
||||
Renvoie une page d'erreur pour l'erreur 404
|
||||
"""
|
||||
return render_template("entreprises/error.html", title="Erreur", e=e)
|
||||
return render_template("entreprises/error.j2", title="Erreur", e=e)
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# 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
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Formulaire changement formation
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import RadioField, SubmitField, validators
|
||||
|
||||
from app.models import Formation
|
||||
|
||||
|
||||
class FormSemestreChangeFormationForm(FlaskForm):
|
||||
"Formulaire changement formation d'un formsemestre"
|
||||
# consrtuit dynamiquement ci-dessous
|
||||
|
||||
|
||||
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
|
||||
|
||||
setattr(
|
||||
F,
|
||||
"radio_but",
|
||||
RadioField(
|
||||
"Label",
|
||||
choices=[
|
||||
(formation.id, formation.get_titre_version())
|
||||
for formation in formations
|
||||
],
|
||||
),
|
||||
)
|
||||
setattr(F, "submit", SubmitField("Changer la formation"))
|
||||
setattr(F, "cancel", SubmitField("Annuler"))
|
||||
return F()
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -35,14 +35,14 @@ from wtforms.fields.simple import StringField
|
|||
|
||||
from app.models import SHORT_STR_LEN
|
||||
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import codes_cursus
|
||||
|
||||
|
||||
def _build_code_field(code):
|
||||
return StringField(
|
||||
label=code,
|
||||
default=code,
|
||||
description=sco_codes_parcours.CODES_EXPL[code],
|
||||
description=codes_cursus.CODES_EXPL[code],
|
||||
validators=[
|
||||
validators.regexp(
|
||||
r"^[A-Z0-9_]*$",
|
||||
|
@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm):
|
|||
ABL = _build_code_field("ABL")
|
||||
ADC = _build_code_field("ADC")
|
||||
ADJ = _build_code_field("ADJ")
|
||||
ADJR = _build_code_field("ADJR")
|
||||
ADM = _build_code_field("ADM")
|
||||
AJ = _build_code_field("AJ")
|
||||
ATB = _build_code_field("ATB")
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# 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
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Formulaire configuration CAS
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, SubmitField
|
||||
from wtforms.fields.simple import FileField, StringField
|
||||
|
||||
|
||||
class ConfigCASForm(FlaskForm):
|
||||
"Formulaire paramétrage CAS"
|
||||
cas_enable = BooleanField("Activer le CAS")
|
||||
cas_force = BooleanField(
|
||||
"Forcer l'utilisation de CAS (tous les utilisateurs seront redirigés vers le CAS)"
|
||||
)
|
||||
|
||||
cas_server = StringField(
|
||||
label="URL du serveur CAS",
|
||||
description="""url complète. Commence en général par <tt>https://</tt>.""",
|
||||
)
|
||||
cas_login_route = StringField(
|
||||
label="Route du login CAS",
|
||||
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt> (si commence par <tt>/</tt>, part de la racine)""",
|
||||
default="/cas",
|
||||
)
|
||||
cas_logout_route = StringField(
|
||||
label="Route du logout CAS",
|
||||
description="""ajouté à l'URL du serveur: exemple <tt>/cas/logout</tt>""",
|
||||
default="/cas/logout",
|
||||
)
|
||||
cas_validate_route = StringField(
|
||||
label="Route de validation CAS",
|
||||
description="""ajouté à l'URL du serveur: exemple <tt>/cas/serviceValidate</tt>""",
|
||||
default="/cas/serviceValidate",
|
||||
)
|
||||
|
||||
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
|
||||
comptes utilisateurs.""",
|
||||
)
|
||||
|
||||
cas_ssl_verify = BooleanField("Vérification du certificat SSL")
|
||||
cas_ssl_certificate_file = FileField(
|
||||
label="Certificat (PEM)",
|
||||
description="""Le contenu du certificat PEM
|
||||
(commence typiquement par <tt>-----BEGIN CERTIFICATE-----</tt>)""",
|
||||
)
|
||||
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -148,6 +148,9 @@ class AddLogoForm(FlaskForm):
|
|||
kwargs["meta"] = {"csrf": False}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def id(self):
|
||||
return f"id=add_{self.dept_key.data}"
|
||||
|
||||
def validate_name(self, name):
|
||||
dept_id = dept_key_to_id(self.dept_key.data)
|
||||
if dept_id == GLOBAL:
|
||||
|
@ -171,7 +174,7 @@ class AddLogoForm(FlaskForm):
|
|||
|
||||
|
||||
class LogoForm(FlaskForm):
|
||||
"""Embed both presentation of a logo (cf. template file configuration.html)
|
||||
"""Embed both presentation of a logo (cf. template file configuration.j2)
|
||||
and all its data and UI action (change, delete)"""
|
||||
|
||||
dept_key = HiddenField()
|
||||
|
@ -227,6 +230,10 @@ class LogoForm(FlaskForm):
|
|||
self.description = "Se substitue au footer défini au niveau global"
|
||||
self.titre = "Logo pied de page"
|
||||
|
||||
def id(self):
|
||||
idstring = f"{self.dept_key.data}_{self.logo_id.data}"
|
||||
return f"id={idstring}"
|
||||
|
||||
def select_action(self):
|
||||
from app.scodoc.sco_config_actions import LogoRename
|
||||
from app.scodoc.sco_config_actions import LogoUpdate
|
||||
|
@ -258,6 +265,9 @@ class DeptForm(FlaskForm):
|
|||
kwargs["meta"] = {"csrf": False}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def id(self):
|
||||
return f"id=DEPT_{self.dept_key.data}"
|
||||
|
||||
def is_local(self):
|
||||
if self.dept_key.data == GLOBAL:
|
||||
return None
|
||||
|
@ -434,7 +444,7 @@ def config_logos():
|
|||
scu.flash_errors(form)
|
||||
|
||||
return render_template(
|
||||
"config_logos.html",
|
||||
"config_logos.j2",
|
||||
scodoc_dept=None,
|
||||
title="Configuration ScoDoc",
|
||||
form=form,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -31,8 +31,8 @@ Formulaires configuration Exports Apogée (codes)
|
|||
|
||||
from flask import flash, url_for, redirect, request, render_template
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, SelectField, SubmitField
|
||||
|
||||
from wtforms import BooleanField, SelectField, StringField, SubmitField
|
||||
from wtforms.validators import Email, Optional
|
||||
import app
|
||||
from app.models import ScoDocSiteConfig
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
@ -54,6 +54,28 @@ class BonusConfigurationForm(FlaskForm):
|
|||
class ScoDocConfigurationForm(FlaskForm):
|
||||
"Panneau de configuration avancée"
|
||||
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
|
||||
month_debut_annee_scolaire = SelectField(
|
||||
label="Mois de début des années scolaires",
|
||||
description="""Date pivot. En France métropolitaine, août.
|
||||
S'applique à tous les départements.""",
|
||||
choices=[
|
||||
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
|
||||
],
|
||||
)
|
||||
month_debut_periode2 = SelectField(
|
||||
label="Mois de début deuxième période de l'année",
|
||||
description="""Date pivot. En France métropolitaine, décembre.
|
||||
S'applique à tous les départements.""",
|
||||
choices=[
|
||||
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
|
||||
],
|
||||
)
|
||||
email_from_addr = StringField(
|
||||
label="Adresse source des mails",
|
||||
description="""adresse email source (from) des mails émis par ScoDoc.
|
||||
Attention: si ce champ peut aussi être défini dans chaque département.""",
|
||||
validators=[Optional(), Email()],
|
||||
)
|
||||
submit_scodoc = SubmitField("Valider")
|
||||
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
@ -67,7 +89,12 @@ def configuration():
|
|||
}
|
||||
)
|
||||
form_scodoc = ScoDocConfigurationForm(
|
||||
data={"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled()}
|
||||
data={
|
||||
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
|
||||
"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"),
|
||||
}
|
||||
)
|
||||
if request.method == "POST" and (
|
||||
form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data
|
||||
|
@ -94,10 +121,28 @@ def configuration():
|
|||
"Module entreprise "
|
||||
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
|
||||
)
|
||||
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
|
||||
int(form_scodoc.data["month_debut_annee_scolaire"])
|
||||
):
|
||||
flash(
|
||||
f"""Début des années scolaires fixé au mois de {
|
||||
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_annee_scolaire()-1]
|
||||
}"""
|
||||
)
|
||||
if ScoDocSiteConfig.set_month_debut_periode2(
|
||||
int(form_scodoc.data["month_debut_periode2"])
|
||||
):
|
||||
flash(
|
||||
f"""Début des années scolaires fixé au mois de {
|
||||
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1]
|
||||
}"""
|
||||
)
|
||||
if ScoDocSiteConfig.set("email_from_addr", form_scodoc.data["email_from_addr"]):
|
||||
flash("Adresse email origine enregistrée")
|
||||
return redirect(url_for("scodoc.index"))
|
||||
|
||||
return render_template(
|
||||
"configuration.html",
|
||||
"configuration.j2",
|
||||
form_bonus=form_bonus,
|
||||
form_scodoc=form_scodoc,
|
||||
scu=scu,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -9,6 +9,7 @@ CODE_STR_LEN = 16 # chaine pour les codes
|
|||
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
|
||||
APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs)
|
||||
GROUPNAME_STR_LEN = 64
|
||||
USERNAME_STR_LEN = 64
|
||||
|
||||
convention = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
|
@ -36,7 +37,7 @@ from app.models.etudiants import (
|
|||
from app.models.events import Scolog, ScolarNews
|
||||
from app.models.formations import Formation, Matiere
|
||||
from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.ues import DispenseUE, UniteEns
|
||||
from app.models.formsemestre import (
|
||||
FormSemestre,
|
||||
FormSemestreEtape,
|
||||
|
@ -72,12 +73,15 @@ from app.models.validations import (
|
|||
from app.models.preferences import ScoPreference
|
||||
|
||||
from app.models.but_refcomp import (
|
||||
ApcReferentielCompetences,
|
||||
ApcCompetence,
|
||||
ApcSituationPro,
|
||||
ApcAppCritique,
|
||||
ApcCompetence,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcReferentielCompetences,
|
||||
ApcSituationPro,
|
||||
)
|
||||
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
||||
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
|
||||
from app.models.assiduites import Assiduite, Justificatif
|
||||
|
|
|
@ -15,8 +15,10 @@ class Absence(db.Model):
|
|||
db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
jour = db.Column(db.Date)
|
||||
# absent / justifié / absent+ justifié
|
||||
estabs = db.Column(db.Boolean())
|
||||
estjust = db.Column(db.Boolean())
|
||||
|
||||
matin = db.Column(db.Boolean())
|
||||
# motif de l'absence:
|
||||
description = db.Column(db.Text())
|
||||
|
@ -24,10 +26,8 @@ class Absence(db.Model):
|
|||
# moduleimpid concerne (optionnel):
|
||||
moduleimpl_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_moduleimpl.id"),
|
||||
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
|
||||
)
|
||||
# XXX TODO: contrainte ajoutée: vérifier suppression du module
|
||||
# (mettre à NULL sans supprimer)
|
||||
|
||||
def to_dict(self):
|
||||
data = {
|
||||
|
|
|
@ -0,0 +1,336 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
"""Gestion de l'assiduité (assiduités + justificatifs)
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from app import db
|
||||
from app.models import ModuleImpl
|
||||
from app.models.etudiants import Identite
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_utils import (
|
||||
EtatAssiduite,
|
||||
EtatJustificatif,
|
||||
localize_datetime,
|
||||
)
|
||||
|
||||
|
||||
class Assiduite(db.Model):
|
||||
"""
|
||||
Représente une assiduité:
|
||||
- une plage horaire lié à un état et un étudiant
|
||||
- un module si spécifiée
|
||||
- une description si spécifiée
|
||||
"""
|
||||
|
||||
__tablename__ = "assiduites"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, nullable=False)
|
||||
assiduite_id = db.synonym("id")
|
||||
|
||||
date_debut = db.Column(
|
||||
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
|
||||
)
|
||||
date_fin = db.Column(
|
||||
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
|
||||
)
|
||||
|
||||
moduleimpl_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
|
||||
)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
etat = db.Column(db.Integer, nullable=False)
|
||||
|
||||
desc = db.Column(db.Text)
|
||||
|
||||
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("user.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
|
||||
|
||||
def to_dict(self, format_api=True) -> dict:
|
||||
"""Retourne la représentation json de l'assiduité"""
|
||||
etat = self.etat
|
||||
|
||||
if format_api:
|
||||
etat = EtatAssiduite.inverse().get(self.etat).name
|
||||
data = {
|
||||
"assiduite_id": self.id,
|
||||
"etudid": self.etudid,
|
||||
"moduleimpl_id": self.moduleimpl_id,
|
||||
"date_debut": self.date_debut,
|
||||
"date_fin": self.date_fin,
|
||||
"etat": etat,
|
||||
"desc": self.desc,
|
||||
"entry_date": self.entry_date,
|
||||
"user_id": self.user_id,
|
||||
"est_just": self.est_just,
|
||||
}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def create_assiduite(
|
||||
cls,
|
||||
etud: Identite,
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatAssiduite,
|
||||
moduleimpl: ModuleImpl = None,
|
||||
description: str = None,
|
||||
entry_date: datetime = None,
|
||||
user_id: int = None,
|
||||
est_just: bool = False,
|
||||
) -> object or int:
|
||||
"""Créer une nouvelle assiduité pour l'étudiant"""
|
||||
# Vérification de non duplication des périodes
|
||||
assiduites: list[Assiduite] = etud.assiduites
|
||||
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
|
||||
raise ScoValueError(
|
||||
"Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
|
||||
)
|
||||
if moduleimpl is not None:
|
||||
# Vérification de l'existence du module pour l'étudiant
|
||||
if moduleimpl.est_inscrit(etud):
|
||||
nouv_assiduite = Assiduite(
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
etat=etat,
|
||||
etudiant=etud,
|
||||
moduleimpl_id=moduleimpl.id,
|
||||
desc=description,
|
||||
entry_date=entry_date,
|
||||
user_id=user_id,
|
||||
est_just=est_just,
|
||||
)
|
||||
else:
|
||||
raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl")
|
||||
else:
|
||||
nouv_assiduite = Assiduite(
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
etat=etat,
|
||||
etudiant=etud,
|
||||
desc=description,
|
||||
entry_date=entry_date,
|
||||
user_id=user_id,
|
||||
est_just=est_just,
|
||||
)
|
||||
|
||||
return nouv_assiduite
|
||||
|
||||
@classmethod
|
||||
def fast_create_assiduite(
|
||||
cls,
|
||||
etudid: int,
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatAssiduite,
|
||||
moduleimpl_id: int = None,
|
||||
description: str = None,
|
||||
entry_date: datetime = None,
|
||||
est_just: bool = False,
|
||||
) -> object or int:
|
||||
"""Créer une nouvelle assiduité pour l'étudiant"""
|
||||
# Vérification de non duplication des périodes
|
||||
|
||||
nouv_assiduite = Assiduite(
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
etat=etat,
|
||||
etudid=etudid,
|
||||
moduleimpl_id=moduleimpl_id,
|
||||
desc=description,
|
||||
entry_date=entry_date,
|
||||
est_just=est_just,
|
||||
)
|
||||
|
||||
return nouv_assiduite
|
||||
|
||||
|
||||
class Justificatif(db.Model):
|
||||
"""
|
||||
Représente un justificatif:
|
||||
- une plage horaire lié à un état et un étudiant
|
||||
- une raison si spécifiée
|
||||
- un fichier si spécifié
|
||||
"""
|
||||
|
||||
__tablename__ = "justificatifs"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
justif_id = db.synonym("id")
|
||||
|
||||
date_debut = db.Column(
|
||||
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
|
||||
)
|
||||
date_fin = db.Column(
|
||||
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
|
||||
)
|
||||
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
etat = db.Column(
|
||||
db.Integer,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("user.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
raison = db.Column(db.Text())
|
||||
|
||||
# Archive_id -> sco_archives_justificatifs.py
|
||||
fichier = db.Column(db.Text())
|
||||
|
||||
def to_dict(self, format_api: bool = False) -> dict:
|
||||
"""transformation de l'objet en dictionnaire sérialisable"""
|
||||
|
||||
etat = self.etat
|
||||
|
||||
if format_api:
|
||||
etat = EtatJustificatif.inverse().get(self.etat).name
|
||||
|
||||
data = {
|
||||
"justif_id": self.justif_id,
|
||||
"etudid": self.etudid,
|
||||
"date_debut": self.date_debut,
|
||||
"date_fin": self.date_fin,
|
||||
"etat": etat,
|
||||
"raison": self.raison,
|
||||
"fichier": self.fichier,
|
||||
"entry_date": self.entry_date,
|
||||
"user_id": self.user_id,
|
||||
}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def create_justificatif(
|
||||
cls,
|
||||
etud: Identite,
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatJustificatif,
|
||||
raison: str = None,
|
||||
entry_date: datetime = None,
|
||||
user_id: int = None,
|
||||
) -> object or int:
|
||||
"""Créer un nouveau justificatif pour l'étudiant"""
|
||||
nouv_justificatif = Justificatif(
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
etat=etat,
|
||||
etudiant=etud,
|
||||
raison=raison,
|
||||
entry_date=entry_date,
|
||||
user_id=user_id,
|
||||
)
|
||||
return nouv_justificatif
|
||||
|
||||
@classmethod
|
||||
def fast_create_justificatif(
|
||||
cls,
|
||||
etudid: int,
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatJustificatif,
|
||||
raison: str = None,
|
||||
entry_date: datetime = None,
|
||||
) -> object or int:
|
||||
"""Créer un nouveau justificatif pour l'étudiant"""
|
||||
|
||||
nouv_justificatif = Justificatif(
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
etat=etat,
|
||||
etudid=etudid,
|
||||
raison=raison,
|
||||
entry_date=entry_date,
|
||||
)
|
||||
|
||||
return nouv_justificatif
|
||||
|
||||
|
||||
def is_period_conflicting(
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
collection: list[Assiduite or Justificatif],
|
||||
collection_cls: Assiduite or Justificatif,
|
||||
) -> bool:
|
||||
"""
|
||||
Vérifie si une date n'entre pas en collision
|
||||
avec les justificatifs ou assiduites déjà présentes
|
||||
"""
|
||||
|
||||
date_debut = localize_datetime(date_debut)
|
||||
date_fin = localize_datetime(date_fin)
|
||||
|
||||
if (
|
||||
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
|
||||
count: int = collection.filter(
|
||||
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
|
||||
).count()
|
||||
|
||||
return count > 0
|
||||
|
||||
|
||||
def compute_assiduites_justified(
|
||||
justificatifs: Justificatif = Justificatif, reset: bool = False
|
||||
) -> list[int]:
|
||||
"""Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud
|
||||
retourne la liste des assiduite_id justifiées
|
||||
|
||||
Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés
|
||||
"""
|
||||
|
||||
list_assiduites_id: set[int] = set()
|
||||
for justi in justificatifs:
|
||||
assiduites: Assiduite = (
|
||||
Assiduite.query.join(Justificatif, Assiduite.etudid == Justificatif.etudid)
|
||||
.filter(Assiduite.etat != EtatAssiduite.PRESENT)
|
||||
.filter(
|
||||
Assiduite.date_debut <= justi.date_fin,
|
||||
Assiduite.date_fin >= justi.date_debut,
|
||||
)
|
||||
)
|
||||
|
||||
for assi in assiduites:
|
||||
assi.est_just = True
|
||||
list_assiduites_id.add(assi.id)
|
||||
db.session.add(assi)
|
||||
|
||||
if reset:
|
||||
un_justified: Assiduite = (
|
||||
Assiduite.query.filter(Assiduite.id.not_in(list_assiduites_id))
|
||||
.join(Justificatif, Assiduite.etudid == Justificatif.etudid)
|
||||
.filter(Assiduite.etat != EtatAssiduite.PRESENT)
|
||||
)
|
||||
for assi in un_justified:
|
||||
assi.est_just = False
|
||||
db.session.add(assi)
|
||||
|
||||
db.session.commit()
|
||||
return
|
|
@ -1,6 +1,6 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
|
||||
|
@ -14,7 +14,7 @@ import sqlalchemy
|
|||
from app import db
|
||||
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
|
||||
|
||||
|
||||
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
||||
|
@ -53,14 +53,18 @@ class XMLModel:
|
|||
class ApcReferentielCompetences(db.Model, XMLModel):
|
||||
"Référentiel de compétence d'une spécialité"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
annexe = db.Column(db.Text())
|
||||
specialite = db.Column(db.Text())
|
||||
specialite_long = db.Column(db.Text())
|
||||
type_titre = db.Column(db.Text())
|
||||
type_structure = db.Column(db.Text())
|
||||
dept_id = db.Column(
|
||||
db.Integer, db.ForeignKey("departement.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
annexe = db.Column(db.Text()) # '1', '22', ...
|
||||
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
|
||||
specialite_long = db.Column(
|
||||
db.Text()
|
||||
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
|
||||
type_titre = db.Column(db.Text()) # 'B.U.T.'
|
||||
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
|
||||
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
|
||||
version_orebut = db.Column(db.Text())
|
||||
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
|
||||
_xml_attribs = { # Orébut xml attrib : attribute
|
||||
"type": "type_titre",
|
||||
"version": "version_orebut",
|
||||
|
@ -86,9 +90,16 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
def __repr__(self):
|
||||
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
|
||||
|
||||
def to_dict(self):
|
||||
def get_version(self) -> str:
|
||||
"La version, normalement sous forme de date iso yyy-mm-dd"
|
||||
if not self.version_orebut:
|
||||
return ""
|
||||
return self.version_orebut.split()[0]
|
||||
|
||||
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
|
||||
"""Représentation complète du ref. de comp.
|
||||
comme un dict.
|
||||
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
|
||||
"""
|
||||
return {
|
||||
"dept_id": self.dept_id,
|
||||
|
@ -103,29 +114,45 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
if self.scodoc_date_loaded
|
||||
else "",
|
||||
"scodoc_orig_filename": self.scodoc_orig_filename,
|
||||
"competences": {x.titre: x.to_dict() for x in self.competences},
|
||||
"parcours": {x.code: x.to_dict() for x in self.parcours},
|
||||
"competences": {
|
||||
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
|
||||
for x in self.competences
|
||||
},
|
||||
"parcours": {
|
||||
x.code: x.to_dict()
|
||||
for x in (self.parcours if parcours is None else parcours)
|
||||
},
|
||||
}
|
||||
|
||||
def get_niveaux_by_parcours(self, annee) -> dict:
|
||||
def get_niveaux_by_parcours(
|
||||
self, annee: int, parcour: "ApcParcours" = None
|
||||
) -> tuple[list["ApcParcours"], dict]:
|
||||
"""
|
||||
Construit la liste des niveaux de compétences pour chaque parcours
|
||||
de ce référentiel.
|
||||
de ce référentiel, ou seulement pour le parcours donné.
|
||||
|
||||
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
|
||||
|
||||
Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
|
||||
on cherche les niveaux qui sont présents dans tous les parcours et les range sous
|
||||
la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
|
||||
|
||||
résultat:
|
||||
{
|
||||
"TC" : [ ApcNiveau ],
|
||||
parcour.id : [ ApcNiveau ]
|
||||
}
|
||||
Résultat: couple
|
||||
( [ ApcParcours ],
|
||||
{
|
||||
"TC" : [ ApcNiveau ],
|
||||
parcour.id : [ ApcNiveau ]
|
||||
}
|
||||
)
|
||||
"""
|
||||
parcours = self.parcours.order_by(ApcParcours.numero).all()
|
||||
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
|
||||
if parcour is None:
|
||||
parcours = parcours_ref
|
||||
else:
|
||||
parcours = [parcour]
|
||||
niveaux_by_parcours = {
|
||||
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
|
||||
for parcour in parcours
|
||||
for parcour in parcours_ref
|
||||
}
|
||||
# Cherche tronc commun
|
||||
if niveaux_by_parcours:
|
||||
|
@ -154,14 +181,37 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
|
||||
]
|
||||
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
|
||||
return niveaux_by_parcours_no_tc
|
||||
return parcours, niveaux_by_parcours_no_tc
|
||||
|
||||
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
|
||||
"""Liste des compétences communes à tous les parcours du référentiel."""
|
||||
parcours = self.parcours.all()
|
||||
if not parcours:
|
||||
return []
|
||||
|
||||
ids = set.intersection(
|
||||
*[
|
||||
{competence.id for competence in parcour.query_competences()}
|
||||
for parcour in parcours
|
||||
]
|
||||
)
|
||||
return sorted(
|
||||
[
|
||||
competence
|
||||
for competence in parcours[0].query_competences()
|
||||
if competence.id in ids
|
||||
],
|
||||
key=lambda c: c.numero or 0,
|
||||
)
|
||||
|
||||
|
||||
class ApcCompetence(db.Model, XMLModel):
|
||||
"Compétence"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
referentiel_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# les compétences dans Orébut sont identifiées par leur id unique
|
||||
# (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
|
||||
|
@ -197,7 +247,7 @@ class ApcCompetence(db.Model, XMLModel):
|
|||
def __repr__(self):
|
||||
return f"<ApcCompetence {self.id} {self.titre!r}>"
|
||||
|
||||
def to_dict(self):
|
||||
def to_dict(self, with_app_critiques=True):
|
||||
"repr dict recursive sur situations, composantes, niveaux"
|
||||
return {
|
||||
"id_orebut": self.id_orebut,
|
||||
|
@ -209,7 +259,10 @@ class ApcCompetence(db.Model, XMLModel):
|
|||
"composantes_essentielles": [
|
||||
x.to_dict() for x in self.composantes_essentielles
|
||||
],
|
||||
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
|
||||
"niveaux": {
|
||||
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
|
||||
for x in self.niveaux
|
||||
},
|
||||
}
|
||||
|
||||
def to_dict_bul(self) -> dict:
|
||||
|
@ -227,7 +280,9 @@ class ApcSituationPro(db.Model, XMLModel):
|
|||
"Situation professionnelle"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
# aucun attribut (le text devient le libellé)
|
||||
|
@ -239,7 +294,9 @@ class ApcComposanteEssentielle(db.Model, XMLModel):
|
|||
"Composante essentielle"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
|
||||
|
@ -257,7 +314,9 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
libelle = db.Column(db.Text(), nullable=False)
|
||||
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
|
||||
|
@ -275,13 +334,15 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
|
||||
self.annee!r} {self.competence!r}>"""
|
||||
|
||||
def to_dict(self):
|
||||
"as a dict, recursif sur les AC"
|
||||
def to_dict(self, with_app_critiques=True):
|
||||
"as a dict, recursif (ou non) sur les AC"
|
||||
return {
|
||||
"libelle": self.libelle,
|
||||
"annee": self.annee,
|
||||
"ordre": self.ordre,
|
||||
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
|
||||
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
|
||||
if with_app_critiques
|
||||
else {},
|
||||
}
|
||||
|
||||
def to_dict_bul(self):
|
||||
|
@ -306,9 +367,8 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
if annee not in {1, 2, 3}:
|
||||
raise ValueError("annee invalide pour un parcours BUT")
|
||||
if referentiel_competence is None:
|
||||
raise ScoValueError(
|
||||
"Pas de référentiel de compétences associé à la formation !"
|
||||
)
|
||||
raise ScoNoReferentielCompetences()
|
||||
|
||||
annee_formation = f"BUT{annee}"
|
||||
if parcour is None:
|
||||
return ApcNiveau.query.filter(
|
||||
|
@ -337,7 +397,7 @@ app_critiques_modules = db.Table(
|
|||
),
|
||||
db.Column(
|
||||
"app_crit_id",
|
||||
db.ForeignKey("apc_app_critique.id"),
|
||||
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
@ -346,7 +406,9 @@ app_critiques_modules = db.Table(
|
|||
class ApcAppCritique(db.Model, XMLModel):
|
||||
"Apprentissage Critique BUT"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False)
|
||||
niveau_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
code = db.Column(db.Text(), nullable=False, index=True)
|
||||
libelle = db.Column(db.Text())
|
||||
|
||||
|
@ -376,7 +438,9 @@ class ApcAppCritique(db.Model, XMLModel):
|
|||
query = query.filter(ApcNiveau.competence == competence)
|
||||
return query
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
def to_dict(self, with_code=False) -> dict:
|
||||
if with_code:
|
||||
return {"code": self.code, "libelle": self.libelle}
|
||||
return {"libelle": self.libelle}
|
||||
|
||||
def get_label(self) -> str:
|
||||
|
@ -393,7 +457,10 @@ class ApcAppCritique(db.Model, XMLModel):
|
|||
parcours_modules = db.Table(
|
||||
"parcours_modules",
|
||||
db.Column(
|
||||
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
|
||||
"parcours_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
"module_id",
|
||||
|
@ -407,7 +474,10 @@ parcours_modules = db.Table(
|
|||
parcours_formsemestre = db.Table(
|
||||
"parcours_formsemestre",
|
||||
db.Column(
|
||||
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
|
||||
"parcours_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
"formsemestre_id",
|
||||
|
@ -420,9 +490,12 @@ parcours_formsemestre = db.Table(
|
|||
|
||||
|
||||
class ApcParcours(db.Model, XMLModel):
|
||||
"Un parcours BUT"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
referentiel_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
|
||||
db.Integer,
|
||||
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
numero = db.Column(db.Integer) # ordre de présentation
|
||||
code = db.Column(db.Text(), nullable=False)
|
||||
|
@ -433,6 +506,7 @@ class ApcParcours(db.Model, XMLModel):
|
|||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
ues = db.relationship("UniteEns", back_populates="parcour")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
|
||||
|
@ -450,11 +524,19 @@ class ApcParcours(db.Model, XMLModel):
|
|||
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
|
||||
return d
|
||||
|
||||
def query_competences(self) -> flask_sqlalchemy.BaseQuery:
|
||||
"Les compétences associées à ce parcours"
|
||||
return (
|
||||
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
|
||||
.filter_by(parcours_id=self.id)
|
||||
.order_by(ApcCompetence.numero)
|
||||
)
|
||||
|
||||
|
||||
class ApcAnneeParcours(db.Model, XMLModel):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parcours_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_parcours.id"), nullable=False
|
||||
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
ordre = db.Column(db.Integer)
|
||||
"numéro de l'année: 1, 2, 3"
|
||||
|
|
|
@ -2,20 +2,18 @@
|
|||
|
||||
"""Décisions de jury (validations) des RCUE et années du BUT
|
||||
"""
|
||||
|
||||
import flask_sqlalchemy
|
||||
from sqlalchemy.sql import text
|
||||
from typing import Union
|
||||
|
||||
from app import db
|
||||
import flask_sqlalchemy
|
||||
|
||||
from app import db
|
||||
from app.models import CODE_STR_LEN
|
||||
from app.models.but_refcomp import ApcNiveau
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.formations import Formation
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.scodoc import sco_codes_parcours as sco_codes
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -24,7 +22,7 @@ class ApcValidationRCUE(db.Model):
|
|||
|
||||
aka "regroupements cohérents d'UE" dans le jargon BUT.
|
||||
|
||||
le formsemestre est celui du semestre PAIR du niveau de compétence
|
||||
Le formsemestre est celui du semestre PAIR du niveau de compétence
|
||||
"""
|
||||
|
||||
__tablename__ = "apc_validation_rcue"
|
||||
|
@ -48,7 +46,9 @@ class ApcValidationRCUE(db.Model):
|
|||
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
|
||||
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
|
||||
# optionnel, le parcours dans lequel se trouve la compétence:
|
||||
parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True)
|
||||
parcours_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="set null"), nullable=True
|
||||
)
|
||||
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
|
||||
|
||||
|
@ -59,13 +59,36 @@ class ApcValidationRCUE(db.Model):
|
|||
parcour = db.relationship("ApcParcours")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>"
|
||||
return f"""<{self.__class__.__name__} {self.id} {self.etud} {
|
||||
self.ue1}/{self.ue2}:{self.code!r}>"""
|
||||
|
||||
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")}"""
|
||||
|
||||
def to_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>"""
|
||||
|
||||
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:
|
||||
"""Le niveau de compétence associé à cet RCUE."""
|
||||
# Par convention, il est donné par la seconde UE
|
||||
return self.ue2.niveau_competence
|
||||
|
||||
def to_dict(self):
|
||||
"as a dict"
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
|
||||
def to_dict_bul(self) -> dict:
|
||||
"Export dict pour bulletins: le code et le niveau de compétence"
|
||||
niveau = self.niveau()
|
||||
|
@ -74,34 +97,41 @@ class ApcValidationRCUE(db.Model):
|
|||
"niveau": None if niveau is None else niveau.to_dict_bul(),
|
||||
}
|
||||
|
||||
def to_dict_codes(self) -> dict:
|
||||
"Dict avec seulement les ids et la date - pour cache table jury"
|
||||
return {
|
||||
"id": self.id,
|
||||
"code": self.code,
|
||||
"date": self.date,
|
||||
"etudid": self.etudid,
|
||||
"niveau_id": self.niveau().id,
|
||||
"formsemestre_id": self.formsemestre_id,
|
||||
}
|
||||
|
||||
|
||||
# Attention: ce n'est pas un modèle mais une classe ordinaire:
|
||||
class RegroupementCoherentUE:
|
||||
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
|
||||
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
|
||||
|
||||
La moyenne (10/20) au RCU déclenche la compensation des UE.
|
||||
La moyenne (10/20) au RCUE déclenche la compensation des UE.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
etud: Identite,
|
||||
formsemestre_1: FormSemestre,
|
||||
ue_1: UniteEns,
|
||||
dec_ue_1: "DecisionsProposeesUE",
|
||||
formsemestre_2: FormSemestre,
|
||||
ue_2: UniteEns,
|
||||
dec_ue_2: "DecisionsProposeesUE",
|
||||
inscription_etat: str,
|
||||
):
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
|
||||
ue_1 = dec_ue_1.ue
|
||||
ue_2 = dec_ue_2.ue
|
||||
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
|
||||
if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
|
||||
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
|
||||
(
|
||||
ue_2,
|
||||
formsemestre_2,
|
||||
),
|
||||
(ue_2, formsemestre_2),
|
||||
(ue_1, formsemestre_1),
|
||||
)
|
||||
assert formsemestre_1.semestre_id % 2 == 1
|
||||
|
@ -121,21 +151,12 @@ class RegroupementCoherentUE:
|
|||
self.moy_ue_1 = self.moy_ue_2 = "-"
|
||||
self.moy_ue_1_val = self.moy_ue_2_val = 0.0
|
||||
return
|
||||
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
|
||||
if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
|
||||
self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
|
||||
self.moy_ue_1_val = self.moy_ue_1 # toujours float, peut être NaN
|
||||
else:
|
||||
self.moy_ue_1 = None
|
||||
self.moy_ue_1_val = 0.0
|
||||
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
|
||||
if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
|
||||
self.moy_ue_2 = res.etud_moy_ue[ue_2.id][etud.id]
|
||||
self.moy_ue_2_val = self.moy_ue_2
|
||||
else:
|
||||
self.moy_ue_2 = None
|
||||
self.moy_ue_2_val = 0.0
|
||||
# Calcul de la moyenne au RCUE
|
||||
self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
|
||||
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
|
||||
self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
|
||||
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
|
||||
|
||||
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
|
||||
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
|
||||
# Moyenne RCUE (les pondérations par défaut sont 1.)
|
||||
self.moy_rcue = (
|
||||
|
@ -145,7 +166,14 @@ class RegroupementCoherentUE:
|
|||
self.moy_rcue = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {self.ue_1.acronyme}({self.moy_ue_1}) {self.ue_2.acronyme}({self.moy_ue_2})>"
|
||||
return f"""<{self.__class__.__name__} {
|
||||
self.ue_1.acronyme}({self.moy_ue_1}) {
|
||||
self.ue_2.acronyme}({self.moy_ue_2})>"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"""RCUE {
|
||||
self.ue_1.acronyme}({self.moy_ue_1}) + {
|
||||
self.ue_2.acronyme}({self.moy_ue_2})"""
|
||||
|
||||
def query_validations(
|
||||
self,
|
||||
|
@ -177,8 +205,9 @@ class RegroupementCoherentUE:
|
|||
return self.query_validations().count() > 0
|
||||
|
||||
def est_compensable(self):
|
||||
"""Vrai si ce RCUE est validable par compensation
|
||||
c'est à dire que sa moyenne est > 10 avec une UE < 10
|
||||
"""Vrai si ce RCUE est validable (uniquement) par compensation
|
||||
c'est à dire que sa moyenne est > 10 avec une UE < 10.
|
||||
Note: si ADM, est_compensable est faux.
|
||||
"""
|
||||
return (
|
||||
(self.moy_rcue is not None)
|
||||
|
@ -214,62 +243,62 @@ class RegroupementCoherentUE:
|
|||
|
||||
|
||||
# unused
|
||||
def find_rcues(
|
||||
formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
|
||||
) -> list[RegroupementCoherentUE]:
|
||||
"""Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
|
||||
ce semestre pour cette UE.
|
||||
# def find_rcues(
|
||||
# formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
|
||||
# ) -> list[RegroupementCoherentUE]:
|
||||
# """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
|
||||
# ce semestre pour cette UE.
|
||||
|
||||
Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
|
||||
En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
|
||||
# Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
|
||||
# En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
|
||||
|
||||
Résultat: la liste peut être vide.
|
||||
"""
|
||||
if (ue.niveau_competence is None) or (ue.semestre_idx is None):
|
||||
return []
|
||||
# Résultat: la liste peut être vide.
|
||||
# """
|
||||
# if (ue.niveau_competence is None) or (ue.semestre_idx is None):
|
||||
# return []
|
||||
|
||||
if ue.semestre_idx % 2: # S1, S3, S5
|
||||
other_semestre_idx = ue.semestre_idx + 1
|
||||
else:
|
||||
other_semestre_idx = ue.semestre_idx - 1
|
||||
# if ue.semestre_idx % 2: # S1, S3, S5
|
||||
# other_semestre_idx = ue.semestre_idx + 1
|
||||
# else:
|
||||
# other_semestre_idx = ue.semestre_idx - 1
|
||||
|
||||
cursor = db.session.execute(
|
||||
text(
|
||||
"""SELECT
|
||||
ue.id, formsemestre.id
|
||||
FROM
|
||||
notes_ue ue,
|
||||
notes_formsemestre_inscription inscr,
|
||||
notes_formsemestre formsemestre
|
||||
# cursor = db.session.execute(
|
||||
# text(
|
||||
# """SELECT
|
||||
# ue.id, formsemestre.id
|
||||
# FROM
|
||||
# notes_ue ue,
|
||||
# notes_formsemestre_inscription inscr,
|
||||
# notes_formsemestre formsemestre
|
||||
|
||||
WHERE
|
||||
inscr.etudid = :etudid
|
||||
AND inscr.formsemestre_id = formsemestre.id
|
||||
|
||||
AND formsemestre.semestre_id = :other_semestre_idx
|
||||
AND ue.formation_id = formsemestre.formation_id
|
||||
AND ue.niveau_competence_id = :ue_niveau_competence_id
|
||||
AND ue.semestre_idx = :other_semestre_idx
|
||||
"""
|
||||
),
|
||||
{
|
||||
"etudid": etud.id,
|
||||
"other_semestre_idx": other_semestre_idx,
|
||||
"ue_niveau_competence_id": ue.niveau_competence_id,
|
||||
},
|
||||
)
|
||||
rcues = []
|
||||
for ue_id, formsemestre_id in cursor:
|
||||
other_ue = UniteEns.query.get(ue_id)
|
||||
other_formsemestre = FormSemestre.query.get(formsemestre_id)
|
||||
rcues.append(
|
||||
RegroupementCoherentUE(
|
||||
etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
|
||||
)
|
||||
)
|
||||
# safety check: 1 seul niveau de comp. concerné:
|
||||
assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
|
||||
return rcues
|
||||
# WHERE
|
||||
# inscr.etudid = :etudid
|
||||
# AND inscr.formsemestre_id = formsemestre.id
|
||||
|
||||
# AND formsemestre.semestre_id = :other_semestre_idx
|
||||
# AND ue.formation_id = formsemestre.formation_id
|
||||
# AND ue.niveau_competence_id = :ue_niveau_competence_id
|
||||
# AND ue.semestre_idx = :other_semestre_idx
|
||||
# """
|
||||
# ),
|
||||
# {
|
||||
# "etudid": etud.id,
|
||||
# "other_semestre_idx": other_semestre_idx,
|
||||
# "ue_niveau_competence_id": ue.niveau_competence_id,
|
||||
# },
|
||||
# )
|
||||
# rcues = []
|
||||
# for ue_id, formsemestre_id in cursor:
|
||||
# other_ue = UniteEns.query.get(ue_id)
|
||||
# other_formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
# rcues.append(
|
||||
# RegroupementCoherentUE(
|
||||
# etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
|
||||
# )
|
||||
# )
|
||||
# # safety check: 1 seul niveau de comp. concerné:
|
||||
# assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
|
||||
# return rcues
|
||||
|
||||
|
||||
class ApcValidationAnnee(db.Model):
|
||||
|
@ -277,7 +306,7 @@ class ApcValidationAnnee(db.Model):
|
|||
|
||||
__tablename__ = "apc_validation_annee"
|
||||
# Assure unicité de la décision:
|
||||
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),)
|
||||
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
|
@ -299,7 +328,11 @@ class ApcValidationAnnee(db.Model):
|
|||
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"
|
||||
return f"""<{self.__class__.__name__} {self.id} {self.etud
|
||||
} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"""
|
||||
|
||||
def __str__(self):
|
||||
return f"""décision sur année BUT{self.ordre} {self.annee_scolaire} : {self.code}"""
|
||||
|
||||
def to_dict_bul(self) -> dict:
|
||||
"dict pour bulletins"
|
||||
|
@ -333,7 +366,8 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
|
|||
titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""")
|
||||
else:
|
||||
titres_rcues.append(
|
||||
f"""{niveau["competence"]["titre"]} {niveau["ordre"]}: {dec_rcue["code"]}"""
|
||||
f"""{niveau["competence"]["titre"]} {niveau["ordre"]}: {
|
||||
dec_rcue["code"]}"""
|
||||
)
|
||||
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
|
||||
decisions["descr_decisions_niveaux"] = (
|
||||
|
|
|
@ -4,15 +4,16 @@
|
|||
"""
|
||||
|
||||
from flask import flash
|
||||
from app import db, log
|
||||
from app import current_app, db, log
|
||||
from app.comp import bonus_spo
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
from app.scodoc.sco_codes_parcours import (
|
||||
from app.scodoc.codes_cursus import (
|
||||
ABAN,
|
||||
ABL,
|
||||
ADC,
|
||||
ADJ,
|
||||
ADJR,
|
||||
ADM,
|
||||
AJ,
|
||||
ATB,
|
||||
|
@ -34,6 +35,7 @@ CODES_SCODOC_TO_APO = {
|
|||
ABL: "ABL",
|
||||
ADC: "ADMC",
|
||||
ADJ: "ADM",
|
||||
ADJR: "ADM",
|
||||
ADM: "ADM",
|
||||
AJ: "AJ",
|
||||
ATB: "AJAC",
|
||||
|
@ -83,6 +85,15 @@ class ScoDocSiteConfig(db.Model):
|
|||
"INSTITUTION_CITY": str,
|
||||
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
|
||||
"enable_entreprises": bool,
|
||||
"month_debut_annee_scolaire": int,
|
||||
"month_debut_periode2": int,
|
||||
# CAS
|
||||
"cas_enable": bool,
|
||||
"cas_server": str,
|
||||
"cas_login_route": str,
|
||||
"cas_logout_route": str,
|
||||
"cas_validate_route": str,
|
||||
"cas_attribute_id": str,
|
||||
}
|
||||
|
||||
def __init__(self, name, value):
|
||||
|
@ -166,7 +177,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
(starting with empty string to represent "no bonus function").
|
||||
"""
|
||||
d = bonus_spo.get_bonus_class_dict()
|
||||
class_list = [(name, d[name].displayed_name) for name in d.keys()]
|
||||
class_list = [(name, d[name].displayed_name) for name in d]
|
||||
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
|
||||
return [("", "")] + class_list
|
||||
|
||||
|
@ -200,13 +211,17 @@ class ScoDocSiteConfig(db.Model):
|
|||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def is_cas_enabled(cls) -> bool:
|
||||
"""True si on utilise le CAS"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").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()
|
||||
if (cfg is None) or not cfg.value:
|
||||
return False
|
||||
return True
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def enable_entreprises(cls, enabled=True) -> bool:
|
||||
|
@ -223,3 +238,99 @@ class ScoDocSiteConfig(db.Model):
|
|||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str, default: str = "") -> str:
|
||||
"Get configuration param; empty string or specified default if unset"
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if cfg is None:
|
||||
return default
|
||||
return cfg.value or ""
|
||||
|
||||
@classmethod
|
||||
def set(cls, name: str, value: str) -> bool:
|
||||
"Set parameter, returns True if change. Commit session."
|
||||
value_str = str(value or "")
|
||||
if (cls.get(name) or "") != value_str:
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if cfg is None:
|
||||
cfg = ScoDocSiteConfig(name=name, value=value_str)
|
||||
else:
|
||||
cfg.value = str(value or "")
|
||||
current_app.logger.info(
|
||||
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
|
||||
)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get_int_field(cls, name: str, default=None) -> int:
|
||||
"""Valeur d'un champs integer"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if (cfg is None) or cfg.value is None:
|
||||
return default
|
||||
return int(cfg.value)
|
||||
|
||||
@classmethod
|
||||
def _set_int_field(
|
||||
cls,
|
||||
name: str,
|
||||
value: int,
|
||||
default=None,
|
||||
range_values: tuple = (),
|
||||
) -> bool:
|
||||
"""Set champs 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])
|
||||
):
|
||||
raise ValueError("invalid value")
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if cfg is None:
|
||||
cfg = ScoDocSiteConfig(name=name, value=str(value))
|
||||
else:
|
||||
cfg.value = str(value)
|
||||
db.session.add(cfg)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_month_debut_annee_scolaire(cls) -> int:
|
||||
"""Mois de début de l'année scolaire."""
|
||||
return cls._get_int_field(
|
||||
"month_debut_annee_scolaire", scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_month_debut_periode2(cls) -> int:
|
||||
"""Mois de début de l'année scolaire."""
|
||||
return cls._get_int_field("month_debut_periode2", scu.MONTH_DEBUT_PERIODE2)
|
||||
|
||||
@classmethod
|
||||
def set_month_debut_annee_scolaire(
|
||||
cls, month: int = scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
||||
) -> bool:
|
||||
"""Fixe le mois de début des années scolaires.
|
||||
True si changement.
|
||||
"""
|
||||
if cls._set_int_field(
|
||||
"month_debut_annee_scolaire", month, scu.MONTH_DEBUT_ANNEE_SCOLAIRE, (1, 12)
|
||||
):
|
||||
log(f"set_month_debut_annee_scolaire({month})")
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def set_month_debut_periode2(cls, month: int = scu.MONTH_DEBUT_PERIODE2) -> bool:
|
||||
"""Fixe le mois de début des années scolaires.
|
||||
True si changement.
|
||||
"""
|
||||
if cls._set_int_field(
|
||||
"month_debut_periode2", month, scu.MONTH_DEBUT_PERIODE2, (1, 12)
|
||||
):
|
||||
log(f"set_month_debut_periode2({month})")
|
||||
return True
|
||||
return False
|
||||
|
|
|
@ -2,12 +2,15 @@
|
|||
|
||||
"""ScoDoc models : departements
|
||||
"""
|
||||
from typing import Any
|
||||
import re
|
||||
|
||||
from app import db
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.preferences import ScoPreference
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
VALID_DEPT_EXP = re.compile(r"^[\w@\\\-\.]+$")
|
||||
|
||||
|
||||
class Departement(db.Model):
|
||||
"""Un département ScoDoc"""
|
||||
|
@ -39,7 +42,7 @@ class Departement(db.Model):
|
|||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>"
|
||||
|
||||
def to_dict(self):
|
||||
def to_dict(self, with_dept_name=True, with_dept_preferences=False):
|
||||
data = {
|
||||
"id": self.id,
|
||||
"acronym": self.acronym,
|
||||
|
@ -47,8 +50,28 @@ class Departement(db.Model):
|
|||
"visible": self.visible,
|
||||
"date_creation": self.date_creation,
|
||||
}
|
||||
if with_dept_name:
|
||||
pref = ScoPreference.query.filter_by(
|
||||
dept_id=self.id, name="DeptName"
|
||||
).first()
|
||||
data["dept_name"] = pref.value if pref else None
|
||||
# Ceci n'est pas encore utilisé, mais pourrait être publié
|
||||
# par l'API après nettoyage des préférences.
|
||||
if with_dept_preferences:
|
||||
data["preferences"] = {
|
||||
p.name: p.value for p in ScoPreference.query.filter_by(dept_id=self.id)
|
||||
}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def invalid_dept_acronym(cls, dept_acronym: str) -> bool:
|
||||
"Check that dept_acronym is invalid"
|
||||
return (
|
||||
not dept_acronym
|
||||
or (len(dept_acronym) >= SHORT_STR_LEN)
|
||||
or not VALID_DEPT_EXP.match(dept_acronym)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_acronym(cls, acronym):
|
||||
dept = cls.query.filter_by(acronym=acronym).first_or_404()
|
||||
|
@ -59,6 +82,8 @@ def create_dept(acronym: str, visible=True) -> Departement:
|
|||
"Create new departement"
|
||||
from app.models import ScoPreference
|
||||
|
||||
if Departement.invalid_dept_acronym(acronym):
|
||||
raise ScoValueError("acronyme departement invalide")
|
||||
existing = Departement.query.filter_by(acronym=acronym).count()
|
||||
if existing:
|
||||
raise ScoValueError(f"acronyme {acronym} déjà existant")
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import abort, has_request_context, url_for
|
||||
from flask import g, request
|
||||
import sqlalchemy
|
||||
|
@ -27,6 +29,7 @@ class Identite(db.Model):
|
|||
__table_args__ = (
|
||||
db.UniqueConstraint("dept_id", "code_nip"),
|
||||
db.UniqueConstraint("dept_id", "code_ine"),
|
||||
db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
@ -36,10 +39,8 @@ class Identite(db.Model):
|
|||
nom = db.Column(db.Text())
|
||||
prenom = db.Column(db.Text())
|
||||
nom_usuel = db.Column(db.Text())
|
||||
# optionnel (si present, affiché à la place du nom)
|
||||
"optionnel (si present, affiché à la place du nom)"
|
||||
civilite = db.Column(db.String(1), nullable=False)
|
||||
__table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),)
|
||||
|
||||
date_naissance = db.Column(db.Date)
|
||||
lieu_naissance = db.Column(db.Text())
|
||||
dept_naissance = db.Column(db.Text())
|
||||
|
@ -58,6 +59,16 @@ class Identite(db.Model):
|
|||
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
|
||||
#
|
||||
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
|
||||
dispense_ues = db.relationship(
|
||||
"DispenseUE",
|
||||
back_populates="etud",
|
||||
cascade="all, delete",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Relations avec les assiduites et les justificatifs
|
||||
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
|
||||
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
|
@ -65,13 +76,30 @@ class Identite(db.Model):
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, etudid=None, code_nip=None):
|
||||
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
|
||||
"""Étudiant à partir de l'etudid ou du code_nip, soit
|
||||
passés en argument soit retrouvés directement dans la requête web.
|
||||
Erreur 404 si inexistant.
|
||||
"""
|
||||
args = make_etud_args(etudid=etudid, code_nip=code_nip)
|
||||
return Identite.query.filter_by(**args).first_or_404()
|
||||
return cls.query.filter_by(**args).first_or_404()
|
||||
|
||||
@classmethod
|
||||
def get_etud(cls, etudid: int) -> "Identite":
|
||||
"""Etudiant ou 404, cherche uniquement dans le département courant"""
|
||||
if g.scodoc_dept:
|
||||
return cls.query.filter_by(
|
||||
id=etudid, dept_id=g.scodoc_dept_id
|
||||
).first_or_404()
|
||||
return cls.query.filter_by(id=etudid).first_or_404()
|
||||
|
||||
@classmethod
|
||||
def create_etud(cls, **args):
|
||||
"Crée un étudiant, avec admission et adresse vides."
|
||||
etud: Identite = cls(**args)
|
||||
etud.adresses.append(Adresse())
|
||||
etud.admission.append(Admission())
|
||||
return etud
|
||||
|
||||
@property
|
||||
def civilite_str(self):
|
||||
|
@ -142,9 +170,19 @@ class Identite(db.Model):
|
|||
)
|
||||
|
||||
def get_first_email(self, field="email") -> str:
|
||||
"Le mail associé à la première adrese de l'étudiant, ou None"
|
||||
"Le mail associé à la première adresse de l'étudiant, ou None"
|
||||
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
|
||||
|
||||
def get_formsemestres(self) -> list:
|
||||
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
|
||||
triée par date_debut
|
||||
"""
|
||||
return sorted(
|
||||
[ins.formsemestre for ins in self.formsemestre_inscriptions],
|
||||
key=attrgetter("date_debut"),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def to_dict_short(self) -> dict:
|
||||
"""Les champs essentiels"""
|
||||
return {
|
||||
|
@ -169,6 +207,10 @@ class Identite(db.Model):
|
|||
e["etudid"] = self.id
|
||||
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
|
||||
e["ne"] = self.e
|
||||
e["nomprenom"] = self.nomprenom
|
||||
adresse = self.adresses.first()
|
||||
if adresse:
|
||||
e.update(adresse.to_dict())
|
||||
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
|
||||
|
||||
def to_dict_bul(self, include_urls=True):
|
||||
|
@ -278,11 +320,18 @@ class Identite(db.Model):
|
|||
inscription_courante = self.inscription_courante()
|
||||
if inscription_courante:
|
||||
titre_sem = inscription_courante.formsemestre.titre_mois()
|
||||
if inscription_courante.etat == scu.DEMISSION:
|
||||
inscr_txt = "Démission de"
|
||||
elif inscription_courante.etat == scu.DEF:
|
||||
inscr_txt = "Défaillant dans"
|
||||
else:
|
||||
inscr_txt = "Inscrit en"
|
||||
|
||||
return {
|
||||
"etat_in_cursem": inscription_courante.etat,
|
||||
"inscription_courante": inscription_courante,
|
||||
"inscription": titre_sem,
|
||||
"inscription_str": "Inscrit en " + titre_sem,
|
||||
"inscription_str": inscr_txt + " " + titre_sem,
|
||||
"situation": self.descr_situation_etud(),
|
||||
}
|
||||
else:
|
||||
|
@ -311,7 +360,7 @@ class Identite(db.Model):
|
|||
"situation": situation,
|
||||
}
|
||||
|
||||
def inscription_etat(self, formsemestre_id):
|
||||
def inscription_etat(self, formsemestre_id: int) -> str:
|
||||
"""État de l'inscription de cet étudiant au semestre:
|
||||
False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
|
||||
"""
|
||||
|
@ -394,14 +443,21 @@ class Identite(db.Model):
|
|||
|
||||
return situation
|
||||
|
||||
def etat_civil_pv(self, line_sep="\n") -> str:
|
||||
def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str:
|
||||
"""Présentation, pour PV jury
|
||||
M. Pierre Dupont
|
||||
n° 12345678
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
Si with_paragraph (défaut):
|
||||
M. Pierre Dupont
|
||||
n° 12345678
|
||||
né(e) le 7/06/1974
|
||||
à Paris
|
||||
Sinon:
|
||||
M. Pierre Dupont
|
||||
"""
|
||||
return f"""{self.nomprenom}{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 ""}{line_sep}à {self.lieu_naissance or ""}"""
|
||||
if with_paragraph:
|
||||
return f"""{self.nomprenom}{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 ""}{
|
||||
line_sep}à {self.lieu_naissance or ""}"""
|
||||
return self.nomprenom
|
||||
|
||||
def photo_html(self, title=None, size="small") -> str:
|
||||
"""HTML img tag for the photo, either in small size (h90)
|
||||
|
|
|
@ -5,12 +5,16 @@
|
|||
import datetime
|
||||
|
||||
from app import db
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models.notes import NotesNotes
|
||||
from app.models.ues import UniteEns
|
||||
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
import app.scodoc.notesdb as ndb
|
||||
|
||||
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Evaluation (contrôle, examen, ...)"""
|
||||
|
@ -44,10 +48,12 @@ class Evaluation(db.Model):
|
|||
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"""<Evaluation {self.id} {self.jour.isoformat() if self.jour else ''} "{self.description[:16] if self.description else ''}">"""
|
||||
return f"""<Evaluation {self.id} {
|
||||
self.jour.isoformat() if self.jour else ''} "{
|
||||
self.description[:16] if self.description else ''}">"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"Représentation dict, pour json"
|
||||
"Représentation dict (riche, compat ScoDoc 7)"
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
# ScoDoc7 output_formators
|
||||
|
@ -67,6 +73,34 @@ class Evaluation(db.Model):
|
|||
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
|
||||
return evaluation_enrich_dict(e)
|
||||
|
||||
def to_dict_api(self) -> dict:
|
||||
"Représentation dict pour API JSON"
|
||||
if self.jour is None:
|
||||
date_debut = None
|
||||
date_fin = None
|
||||
else:
|
||||
date_debut = datetime.datetime.combine(
|
||||
self.jour, self.heure_debut or datetime.time(0, 0)
|
||||
).isoformat()
|
||||
date_fin = datetime.datetime.combine(
|
||||
self.jour, self.heure_fin or datetime.time(0, 0)
|
||||
).isoformat()
|
||||
|
||||
return {
|
||||
"coefficient": self.coefficient,
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_fin,
|
||||
"description": self.description,
|
||||
"evaluation_type": self.evaluation_type,
|
||||
"id": self.id,
|
||||
"moduleimpl_id": self.moduleimpl_id,
|
||||
"note_max": self.note_max,
|
||||
"numero": self.numero,
|
||||
"poids": self.get_ue_poids_dict(),
|
||||
"publish_incomplete": self.publish_incomplete,
|
||||
"visi_bulletin": self.visibulletin,
|
||||
}
|
||||
|
||||
def from_dict(self, data):
|
||||
"""Set evaluation attributes from given dict values."""
|
||||
check_evaluation_args(data)
|
||||
|
@ -74,6 +108,29 @@ class Evaluation(db.Model):
|
|||
if k != "_sa_instance_state" and k != "id" and k in data:
|
||||
setattr(self, k, data[k])
|
||||
|
||||
def descr_heure(self) -> str:
|
||||
"Description de la plage horaire pour affichages"
|
||||
if self.heure_debut and (
|
||||
not self.heure_fin or self.heure_fin == self.heure_debut
|
||||
):
|
||||
return f"""à {self.heure_debut.strftime("%Hh%M")}"""
|
||||
elif self.heure_debut and self.heure_fin:
|
||||
return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
def descr_duree(self) -> str:
|
||||
"Description de la durée pour affichages"
|
||||
if self.heure_debut is None and self.heure_fin is None:
|
||||
return ""
|
||||
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
|
||||
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
|
||||
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
|
||||
duree = f"{d//60}h"
|
||||
if d % 60:
|
||||
duree += f"{d%60:02d}"
|
||||
return duree
|
||||
|
||||
def clone(self, not_copying=()):
|
||||
"""Clone, not copying the given attrs
|
||||
Attention: la copie n'a pas d'id avant le prochain commit
|
||||
|
@ -87,6 +144,29 @@ class Evaluation(db.Model):
|
|||
db.session.add(copy)
|
||||
return copy
|
||||
|
||||
def set_default_poids(self) -> bool:
|
||||
"""Initialize les poids bvers les UE à leurs valeurs par défaut
|
||||
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
|
||||
Les poids existants ne sont pas modifiés.
|
||||
Return True if (uncommited) modification, False otherwise.
|
||||
"""
|
||||
ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict()
|
||||
sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all()
|
||||
modified = False
|
||||
for ue in sem_ues:
|
||||
existing_poids = EvaluationUEPoids.query.filter_by(
|
||||
ue=ue, evaluation=self
|
||||
).first()
|
||||
if existing_poids is None:
|
||||
coef_ue = ue_coef_dict.get(ue.id, 0.0) or 0.0
|
||||
if coef_ue > 0:
|
||||
poids = 1.0 # par défaut au départ
|
||||
else:
|
||||
poids = 0.0
|
||||
self.set_ue_poids(ue, poids)
|
||||
modified = True
|
||||
return modified
|
||||
|
||||
def set_ue_poids(self, ue, poids: float) -> None:
|
||||
"""Set poids évaluation vers cette UE"""
|
||||
self.update_ue_poids_dict({ue.id: poids})
|
||||
|
@ -99,7 +179,7 @@ class Evaluation(db.Model):
|
|||
for ue_id, poids in ue_poids_dict.items():
|
||||
ue = UniteEns.query.get(ue_id)
|
||||
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
|
||||
self.ue_poids = L
|
||||
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
|
||||
self.moduleimpl.invalidate_evaluations_poids() # inval cache
|
||||
|
||||
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
|
||||
|
@ -108,8 +188,18 @@ class Evaluation(db.Model):
|
|||
current.update(ue_poids_dict)
|
||||
self.set_ue_poids_dict(current)
|
||||
|
||||
def get_ue_poids_dict(self) -> dict:
|
||||
"""returns { ue_id : poids }"""
|
||||
def get_ue_poids_dict(self, sort=False) -> dict:
|
||||
"""returns { ue_id : poids }
|
||||
Si sort, trie par UE
|
||||
"""
|
||||
if sort:
|
||||
return {
|
||||
p.ue.id: p.poids
|
||||
for p in sorted(
|
||||
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
|
||||
)
|
||||
}
|
||||
|
||||
return {p.ue.id: p.poids for p in self.ue_poids}
|
||||
|
||||
def get_ue_poids_str(self) -> str:
|
||||
|
@ -130,6 +220,12 @@ class Evaluation(db.Model):
|
|||
]
|
||||
)
|
||||
|
||||
def get_etud_note(self, etud: Identite) -> NotesNotes:
|
||||
"""La note de l'étudiant, ou None si pas noté.
|
||||
(nb: pas de cache, lent, ne pas utiliser pour des calculs)
|
||||
"""
|
||||
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
|
||||
|
||||
|
||||
class EvaluationUEPoids(db.Model):
|
||||
"""Poids des évaluations (BUT)
|
||||
|
@ -164,7 +260,7 @@ class EvaluationUEPoids(db.Model):
|
|||
|
||||
|
||||
# Fonction héritée de ScoDoc7 à refactorer
|
||||
def evaluation_enrich_dict(e):
|
||||
def evaluation_enrich_dict(e: dict):
|
||||
"""add or convert some fields in an evaluation dict"""
|
||||
# For ScoDoc7 compat
|
||||
heure_debut_dt = e["heure_debut"] or datetime.time(
|
||||
|
@ -173,7 +269,7 @@ def evaluation_enrich_dict(e):
|
|||
heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
|
||||
e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
|
||||
e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"])
|
||||
e["jouriso"] = ndb.DateDMYtoISO(e["jour"])
|
||||
e["jour_iso"] = ndb.DateDMYtoISO(e["jour"])
|
||||
heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
|
||||
d = ndb.TimeDuration(heure_debut, heure_fin)
|
||||
if d is not None:
|
||||
|
|
|
@ -13,7 +13,6 @@ from app import email
|
|||
from app import log
|
||||
from app.auth.models import User
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc import sco_preferences
|
||||
|
@ -170,10 +169,12 @@ class ScolarNews(db.Model):
|
|||
log(f"news: {news}")
|
||||
news.notify_by_mail()
|
||||
|
||||
def get_news_formsemestre(self) -> FormSemestre:
|
||||
def get_news_formsemestre(self) -> "FormSemestre":
|
||||
"""formsemestre concerné par la nouvelle
|
||||
None si inexistant
|
||||
"""
|
||||
from app.models.formsemestre import FormSemestre
|
||||
|
||||
formsemestre_id = None
|
||||
if self.type == self.NEWS_INSCR:
|
||||
formsemestre_id = self.object
|
||||
|
@ -232,8 +233,7 @@ class ScolarNews(db.Model):
|
|||
txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt)
|
||||
|
||||
subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
|
||||
sender = prefs["email_from_addr"]
|
||||
|
||||
sender = email.get_from_addr()
|
||||
email.send_email(subject, sender, destinations, txt)
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -17,9 +17,9 @@ from app.models.modules import Module
|
|||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_codes_parcours import UE_STANDARD
|
||||
from app.scodoc.codes_cursus import UE_STANDARD
|
||||
|
||||
|
||||
class Formation(db.Model):
|
||||
|
@ -36,6 +36,7 @@ class Formation(db.Model):
|
|||
titre = db.Column(db.Text(), nullable=False)
|
||||
titre_officiel = db.Column(db.Text(), nullable=False)
|
||||
version = db.Column(db.Integer, default=1, server_default="1")
|
||||
commentaire = db.Column(db.Text())
|
||||
formation_code = db.Column(
|
||||
db.String(SHORT_STR_LEN),
|
||||
server_default=db.text("notes_newid_fcod()"),
|
||||
|
@ -47,7 +48,7 @@ class Formation(db.Model):
|
|||
|
||||
# Optionnel, pour les formations type BUT
|
||||
referentiel_competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id")
|
||||
db.Integer, db.ForeignKey("apc_referentiel_competences.id", ondelete="SET NULL")
|
||||
)
|
||||
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
|
||||
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
|
||||
|
@ -55,26 +56,41 @@ class Formation(db.Model):
|
|||
modules = db.relationship("Module", lazy="dynamic", backref="formation")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
|
||||
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
|
||||
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
|
||||
|
||||
def to_html(self) -> str:
|
||||
"titre complet pour affichage"
|
||||
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
|
||||
|
||||
def to_dict(self):
|
||||
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.
|
||||
"""
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
e["departement"] = self.departement.to_dict()
|
||||
# ScoDoc7 output_formators: (backward compat)
|
||||
e["formation_id"] = self.id
|
||||
if "referentiel_competence" in e:
|
||||
e.pop("referentiel_competence")
|
||||
e["code_specialite"] = e["code_specialite"] or ""
|
||||
e["commentaire"] = e["commentaire"] or ""
|
||||
if with_departement and self.departement:
|
||||
e["departement"] = self.departement.to_dict()
|
||||
else:
|
||||
e.pop("departement", None)
|
||||
e["formation_id"] = self.id # ScoDoc7 backward compat
|
||||
if with_refcomp_attrs and self.referentiel_competence:
|
||||
e["refcomp_version_orebut"] = self.referentiel_competence.version_orebut
|
||||
e["refcomp_specialite"] = self.referentiel_competence.specialite
|
||||
e["refcomp_type_titre"] = self.referentiel_competence.type_titre
|
||||
|
||||
return e
|
||||
|
||||
def get_parcours(self):
|
||||
"""get l'instance de TypeParcours de cette formation
|
||||
(le TypeParcours définit le genre de formation, à ne pas confondre
|
||||
def get_cursus(self) -> codes_cursus.TypeCursus:
|
||||
"""get l'instance de TypeCursus de cette formation
|
||||
(le TypeCursus définit le genre de formation, à ne pas confondre
|
||||
avec les parcours du BUT).
|
||||
"""
|
||||
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
|
||||
return codes_cursus.get_cursus_from_code(self.type_parcours)
|
||||
|
||||
def get_titre_version(self) -> str:
|
||||
"""Titre avec version"""
|
||||
|
@ -82,7 +98,7 @@ class Formation(db.Model):
|
|||
|
||||
def is_apc(self):
|
||||
"True si formation APC avec SAE (BUT)"
|
||||
return self.get_parcours().APC_SAE
|
||||
return self.get_cursus().APC_SAE
|
||||
|
||||
def get_module_coefs(self, semestre_idx: int = None):
|
||||
"""Les coefs des modules vers les UE (accès via cache)"""
|
||||
|
@ -101,9 +117,14 @@ class Formation(db.Model):
|
|||
df_cache.ModuleCoefsCache.set(key, modules_coefficients)
|
||||
return modules_coefficients
|
||||
|
||||
def has_locked_sems(self):
|
||||
"True if there is a locked formsemestre in this formation"
|
||||
return len(self.formsemestres.filter_by(etat=False).all()) > 0
|
||||
def has_locked_sems(self, semestre_idx: int = None):
|
||||
"""True if there is a locked formsemestre in this formation.
|
||||
If semestre_idx is specified, check only this index.
|
||||
"""
|
||||
query = self.formsemestres.filter_by(etat=False)
|
||||
if semestre_idx is not None:
|
||||
query = query.filter_by(semestre_id=semestre_idx)
|
||||
return len(query.all()) > 0
|
||||
|
||||
def invalidate_module_coefs(self, semestre_idx: int = None):
|
||||
"""Invalide le cache des coefficients de modules.
|
||||
|
@ -194,12 +215,17 @@ class Formation(db.Model):
|
|||
|
||||
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
|
||||
"""Les UEs d'un parcours de la formation.
|
||||
Si parcour est None, les UE sans parcours.
|
||||
Exemple: pour avoir les UE du semestre 3, faire
|
||||
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
|
||||
"""
|
||||
return UniteEns.query.filter_by(formation=self).filter(
|
||||
if parcour is None:
|
||||
return UniteEns.query.filter_by(
|
||||
formation=self, type=UE_STANDARD, parcour_id=None
|
||||
)
|
||||
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
|
||||
UniteEns.niveau_competence_id == ApcNiveau.id,
|
||||
UniteEns.type == UE_STANDARD,
|
||||
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
|
||||
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
|
||||
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
|
||||
ApcAnneeParcours.parcours_id == parcour.id,
|
||||
|
@ -226,6 +252,21 @@ class Formation(db.Model):
|
|||
.filter(ApcAnneeParcours.parcours_id == parcour.id)
|
||||
)
|
||||
|
||||
def refcomp_desassoc(self):
|
||||
"""Désassocie la formation de son ref. de compétence"""
|
||||
self.referentiel_competence = None
|
||||
db.session.add(self)
|
||||
# Niveaux des UE
|
||||
for ue in self.ues:
|
||||
ue.niveau_competence = None
|
||||
db.session.add(ue)
|
||||
# Parcours et AC des modules
|
||||
for mod in self.modules:
|
||||
mod.parcours = []
|
||||
mod.app_critiques = []
|
||||
db.session.add(mod)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class Matiere(db.Model):
|
||||
"""Matières: regroupe les modules d'une UE
|
||||
|
@ -253,6 +294,6 @@ class Matiere(db.Model):
|
|||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
# ScoDoc7 output_formators
|
||||
e["ue_id"] = self.id
|
||||
e["numero"] = e["numero"] if e["numero"] else 0
|
||||
e["ue_id"] = self.id
|
||||
return e
|
||||
|
|
|
@ -1,45 +1,49 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
# pylint génère trop de faux positifs avec les colonnes date:
|
||||
# pylint: disable=no-member,not-an-iterable
|
||||
|
||||
"""ScoDoc models: formsemestre
|
||||
"""
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
|
||||
from flask import flash, g
|
||||
from flask_login import current_user
|
||||
import flask_sqlalchemy
|
||||
from flask import flash, g
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from app import db
|
||||
from app import log
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models import CODE_STR_LEN
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import db, log
|
||||
from app.auth.models import User
|
||||
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
|
||||
from app.models.but_refcomp import (
|
||||
ApcAnneeParcours,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcParcoursNiveauCompetence,
|
||||
ApcReferentielCompetences,
|
||||
parcours_formsemestre,
|
||||
)
|
||||
from app.models.groups import GroupDescr, Partition
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.models.but_refcomp import parcours_formsemestre
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formations import Formation
|
||||
from app.models.groups import GroupDescr, Partition
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
from app.models.modules import Module
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
from app.models.ues import UniteEns
|
||||
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.sco_vdi import ApoEtapeVDI
|
||||
from app.models.validations import ScolarFormSemestreValidation
|
||||
from app.scodoc import codes_cursus, sco_preferences
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
|
||||
from app.scodoc.sco_vdi import ApoEtapeVDI
|
||||
|
||||
|
||||
class FormSemestre(db.Model):
|
||||
|
@ -54,51 +58,58 @@ class FormSemestre(db.Model):
|
|||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
|
||||
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
||||
titre = db.Column(db.Text())
|
||||
date_debut = db.Column(db.Date())
|
||||
date_fin = db.Column(db.Date())
|
||||
etat = db.Column(
|
||||
db.Boolean(), nullable=False, default=True, server_default="true"
|
||||
) # False si verrouillé
|
||||
titre = db.Column(db.Text(), nullable=False)
|
||||
date_debut = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False)
|
||||
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
|
||||
"False si verrouillé"
|
||||
modalite = db.Column(
|
||||
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
|
||||
) # "FI", "FAP", "FC", ...
|
||||
# gestion compensation sem DUT:
|
||||
)
|
||||
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
|
||||
gestion_compensation = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# ne publie pas le bulletin XML ou JSON:
|
||||
"gestion compensation sem DUT (inutilisé en APC)"
|
||||
bul_hide_xml = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# Bloque le calcul des moyennes (générale et d'UE)
|
||||
"ne publie pas le bulletin XML ou JSON"
|
||||
block_moyennes = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# semestres decales (pour gestion jurys):
|
||||
"Bloque le calcul des moyennes (générale et d'UE)"
|
||||
block_moyenne_generale = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
|
||||
gestion_semestrielle = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# couleur fond bulletins HTML:
|
||||
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
|
||||
bul_bgcolor = db.Column(
|
||||
db.String(SHORT_STR_LEN), default="white", server_default="white"
|
||||
db.String(SHORT_STR_LEN),
|
||||
default="white",
|
||||
server_default="white",
|
||||
nullable=False,
|
||||
)
|
||||
# autorise resp. a modifier semestre:
|
||||
"couleur fond bulletins HTML"
|
||||
resp_can_edit = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# autorise resp. a modifier slt les enseignants:
|
||||
"autorise resp. à modifier le formsemestre"
|
||||
resp_can_change_ens = db.Column(
|
||||
db.Boolean(), nullable=False, default=True, server_default="true"
|
||||
)
|
||||
# autorise les ens a creer des evals:
|
||||
"autorise resp. a modifier slt les enseignants"
|
||||
ens_can_edit_eval = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="False"
|
||||
)
|
||||
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
|
||||
"autorise les enseignants à créer des évals dans leurs modimpls"
|
||||
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
|
||||
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
|
||||
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
|
||||
elt_annee_apo = db.Column(db.Text())
|
||||
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
|
||||
|
||||
# Relations:
|
||||
etapes = db.relationship(
|
||||
|
@ -108,6 +119,7 @@ class FormSemestre(db.Model):
|
|||
"ModuleImpl",
|
||||
backref="formsemestre",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
etuds = db.relationship(
|
||||
"Identite",
|
||||
|
@ -145,7 +157,21 @@ class FormSemestre(db.Model):
|
|||
self.modalite = FormationModalite.DEFAULT_MODALITE
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>"
|
||||
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
|
||||
|
||||
@classmethod
|
||||
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
|
||||
""" "FormSemestre ou 404, cherche uniquement dans le département courant"""
|
||||
if g.scodoc_dept:
|
||||
return cls.query.filter_by(
|
||||
id=formsemestre_id, dept_id=g.scodoc_dept_id
|
||||
).first_or_404()
|
||||
return cls.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
|
||||
def sort_key(self) -> tuple:
|
||||
"""clé pour tris par ordre alphabétique
|
||||
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
|
||||
return (self.date_debut, self.semestre_id)
|
||||
|
||||
def to_dict(self, convert_objects=False) -> dict:
|
||||
"""dict (compatible ScoDoc7).
|
||||
|
@ -170,7 +196,7 @@ class FormSemestre(db.Model):
|
|||
d["responsables"] = [u.id for u in self.responsables]
|
||||
d["titre_formation"] = self.titre_formation()
|
||||
if convert_objects:
|
||||
d["parcours"] = [p.to_dict() for p in self.parcours]
|
||||
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
||||
d["departement"] = self.departement.to_dict()
|
||||
d["formation"] = self.formation.to_dict()
|
||||
d["etape_apo"] = self.etapes_apo_str()
|
||||
|
@ -197,9 +223,10 @@ class FormSemestre(db.Model):
|
|||
d["etape_apo"] = self.etapes_apo_str()
|
||||
d["formsemestre_id"] = self.id
|
||||
d["formation"] = self.formation.to_dict()
|
||||
d["parcours"] = [p.to_dict() for p in self.parcours]
|
||||
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
||||
d["responsables"] = [u.id for u in self.responsables]
|
||||
d["titre_court"] = self.formation.acronyme
|
||||
d["titre_formation"] = self.titre_formation()
|
||||
d["titre_num"] = self.titre_num()
|
||||
d["session_id"] = self.session_id()
|
||||
return d
|
||||
|
@ -219,7 +246,8 @@ class FormSemestre(db.Model):
|
|||
d["mois_debut_ord"] = self.date_debut.month
|
||||
d["mois_fin_ord"] = self.date_fin.month
|
||||
# La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
|
||||
# devrait sans doute pouvoir etre changé...
|
||||
# devrait sans doute pouvoir etre changé... XXX PIVOT
|
||||
d["periode"] = self.periode()
|
||||
if self.date_debut.month >= 8 and self.date_debut.month <= 10:
|
||||
d["periode"] = 1 # typiquement, début en septembre: S1, S3...
|
||||
else:
|
||||
|
@ -238,17 +266,41 @@ class FormSemestre(db.Model):
|
|||
d["etapes_apo_str"] = self.etapes_apo_str()
|
||||
return d
|
||||
|
||||
def flip_lock(self):
|
||||
"""Flip etat (lock)"""
|
||||
self.etat = not self.etat
|
||||
db.session.add(self)
|
||||
|
||||
def get_parcours_apc(self) -> list[ApcParcours]:
|
||||
"""Liste des parcours proposés par ce semestre.
|
||||
Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel.
|
||||
"""
|
||||
r = self.parcours or (
|
||||
self.formation.referentiel_competence
|
||||
and self.formation.referentiel_competence.parcours
|
||||
)
|
||||
return r or []
|
||||
|
||||
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
|
||||
"""UE des modules de ce semestre, triées par numéro.
|
||||
- Formations classiques: les UEs auxquelles appartiennent
|
||||
les modules mis en place dans ce semestre.
|
||||
- Formations APC / BUT: les UEs de la formation qui ont
|
||||
le même numéro de semestre que ce formsemestre.
|
||||
- Formations APC / BUT: les UEs de la formation qui
|
||||
- ont le même numéro de semestre que ce formsemestre
|
||||
- sont associées à l'un des parcours de ce formsemestre (ou à aucun)
|
||||
|
||||
"""
|
||||
if self.formation.get_parcours().APC_SAE:
|
||||
if self.formation.get_cursus().APC_SAE:
|
||||
sem_ues = UniteEns.query.filter_by(
|
||||
formation=self.formation, semestre_idx=self.semestre_id
|
||||
)
|
||||
if self.parcours:
|
||||
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours
|
||||
sem_ues = sem_ues.filter(
|
||||
(UniteEns.parcour == None)
|
||||
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
|
||||
)
|
||||
# si le sem. ne coche aucun parcours, prend toutes les UE
|
||||
else:
|
||||
sem_ues = db.session.query(UniteEns).filter(
|
||||
ModuleImpl.formsemestre_id == self.id,
|
||||
|
@ -256,12 +308,15 @@ class FormSemestre(db.Model):
|
|||
UniteEns.id == Module.ue_id,
|
||||
)
|
||||
if not with_sport:
|
||||
sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
|
||||
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
|
||||
return sem_ues.order_by(UniteEns.numero)
|
||||
|
||||
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
|
||||
"""UE que suit l'étudiant dans ce semestre BUT
|
||||
"""XXX inutilisé à part pour un test unitaire => supprimer ?
|
||||
UEs que suit l'étudiant dans ce semestre BUT
|
||||
en fonction du parcours dans lequel il est inscrit.
|
||||
Si l'étudiant n'est inscrit à aucun parcours,
|
||||
renvoie uniquement les UEs de tronc commun (sans parcours).
|
||||
|
||||
Si voulez les UE d'un parcours, il est plus efficace de passer par
|
||||
`formation.query_ues_parcour(parcour)`.
|
||||
|
@ -272,7 +327,13 @@ class FormSemestre(db.Model):
|
|||
UniteEns.niveau_competence_id == ApcNiveau.id,
|
||||
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
|
||||
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
|
||||
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
|
||||
or_(
|
||||
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
|
||||
and_(
|
||||
FormSemestreInscription.parcour_id.is_(None),
|
||||
UniteEns.parcour_id.is_(None),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
|
@ -285,7 +346,7 @@ class FormSemestre(db.Model):
|
|||
if self.formation.is_apc():
|
||||
modimpls.sort(
|
||||
key=lambda m: (
|
||||
m.module.module_type or 0,
|
||||
m.module.module_type or 0, # ressources (2) avant SAEs (3)
|
||||
m.module.numero or 0,
|
||||
m.module.code or 0,
|
||||
)
|
||||
|
@ -324,7 +385,7 @@ class FormSemestre(db.Model):
|
|||
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Vrai si user peut modifier ce semestre"""
|
||||
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
|
||||
if not user.has_permission(Permission.ScoImplement): # pas chef
|
||||
if not self.resp_can_edit or user.id not in [
|
||||
resp.id for resp in self.responsables
|
||||
|
@ -338,7 +399,7 @@ class FormSemestre(db.Model):
|
|||
(les dates de début et fin sont incluses)
|
||||
"""
|
||||
today = datetime.date.today()
|
||||
return (self.date_debut <= today) and (today <= self.date_fin)
|
||||
return self.date_debut <= today <= self.date_fin
|
||||
|
||||
def contient_periode(self, date_debut, date_fin) -> bool:
|
||||
"""Vrai si l'intervalle [date_debut, date_fin] est
|
||||
|
@ -351,29 +412,105 @@ class FormSemestre(db.Model):
|
|||
"""Test si sem est entièrement sur la même année scolaire.
|
||||
(ce n'est pas obligatoire mais si ce n'est pas le
|
||||
cas les exports Apogée risquent de mal fonctionner)
|
||||
Pivot au 1er août.
|
||||
Pivot au 1er août par défaut.
|
||||
"""
|
||||
if self.date_debut > self.date_fin:
|
||||
flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
|
||||
log(f"Warning: semestre {self.id} begins after ending !")
|
||||
annee_debut = self.date_debut.year
|
||||
if self.date_debut.month < 8: # août
|
||||
# considere que debut sur l'anne scolaire precedente
|
||||
month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
|
||||
if self.date_debut.month < month_debut_annee:
|
||||
# début sur l'année scolaire précédente (juillet inclus par défaut)
|
||||
annee_debut -= 1
|
||||
annee_fin = self.date_fin.year
|
||||
if self.date_fin.month < 9:
|
||||
# 9 (sept) pour autoriser un début en sept et une fin en aout
|
||||
if self.date_fin.month < (month_debut_annee + 1):
|
||||
# 9 (sept) pour autoriser un début en sept et une fin en août
|
||||
annee_fin -= 1
|
||||
return annee_debut == annee_fin
|
||||
|
||||
def est_decale(self):
|
||||
"""Vrai si semestre "décalé"
|
||||
c'est à dire semestres impairs commençant entre janvier et juin
|
||||
et les pairs entre juillet et decembre
|
||||
c'est à dire semestres impairs commençant (par défaut)
|
||||
entre janvier et juin et les pairs entre juillet et décembre.
|
||||
"""
|
||||
if self.semestre_id <= 0:
|
||||
return False # formations sans semestres
|
||||
return (self.semestre_id % 2 and self.date_debut.month <= 6) or (
|
||||
not self.semestre_id % 2 and self.date_debut.month > 6
|
||||
return (
|
||||
# impair
|
||||
(
|
||||
self.semestre_id % 2
|
||||
and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
||||
)
|
||||
or
|
||||
# pair
|
||||
(
|
||||
(not self.semestre_id % 2)
|
||||
and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
||||
)
|
||||
)
|
||||
|
||||
def est_terminal(self) -> bool:
|
||||
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
|
||||
return (self.semestre_id < 0) or (
|
||||
self.semestre_id == self.formation.get_cursus().NB_SEM
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def comp_periode(
|
||||
cls,
|
||||
date_debut: datetime,
|
||||
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
|
||||
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
|
||||
jour_pivot_annee=1,
|
||||
jour_pivot_periode=1,
|
||||
):
|
||||
"""Calcule la session associée à un formsemestre commençant en date_debut
|
||||
sous la forme (année, période)
|
||||
année: première année de l'année scolaire
|
||||
période = 1 (première période de l'année scolaire, souvent automne)
|
||||
ou 2 (deuxième période de l'année scolaire, souvent printemps)
|
||||
Les quatre derniers paramètres forment les dates pivots pour l'année
|
||||
(1er août par défaut) et pour la période (1er décembre par défaut).
|
||||
|
||||
Les calculs se font à partir de la date de début indiquée.
|
||||
Exemples dans tests/unit/test_periode
|
||||
|
||||
Implémentation:
|
||||
Cas à considérer pour le calcul de la période
|
||||
|
||||
pa < pp -----------------|-------------------|---------------->
|
||||
(A-1, P:2) pa (A, P:1) pp (A, P:2)
|
||||
pp < pa -----------------|-------------------|---------------->
|
||||
(A-1, P:1) pp (A-1, P:2) pa (A, P:1)
|
||||
"""
|
||||
pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee
|
||||
pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode
|
||||
pivot_sem = 100 * date_debut.month + date_debut.day
|
||||
if pivot_sem < pivot_annee:
|
||||
annee = date_debut.year - 1
|
||||
else:
|
||||
annee = date_debut.year
|
||||
if pivot_annee < pivot_periode:
|
||||
if pivot_sem < pivot_annee or pivot_sem >= pivot_periode:
|
||||
periode = 2
|
||||
else:
|
||||
periode = 1
|
||||
else:
|
||||
if pivot_sem < pivot_periode or pivot_sem >= pivot_annee:
|
||||
periode = 1
|
||||
else:
|
||||
periode = 2
|
||||
return annee, periode
|
||||
|
||||
def periode(self) -> int:
|
||||
"""La période:
|
||||
* 1 : première période: automne à Paris
|
||||
* 2 : deuxième période, printemps à Paris
|
||||
"""
|
||||
return FormSemestre.comp_periode(
|
||||
self.date_debut,
|
||||
mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(),
|
||||
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
|
||||
)
|
||||
|
||||
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
|
||||
|
@ -420,13 +557,35 @@ class FormSemestre(db.Model):
|
|||
else:
|
||||
return ", ".join([u.get_nomcomplet() for u in self.responsables])
|
||||
|
||||
def est_responsable(self, user):
|
||||
def est_responsable(self, user: User):
|
||||
"True si l'user est l'un des responsables du semestre"
|
||||
return user.id in [u.id for u in self.responsables]
|
||||
|
||||
def est_chef_or_diretud(self, user: User = None):
|
||||
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
|
||||
user = user or current_user
|
||||
return user.has_permission(Permission.ScoImplement) or self.est_responsable(
|
||||
user
|
||||
)
|
||||
|
||||
def can_edit_jury(self, user: User = None):
|
||||
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
|
||||
dans ce semestre: vérifie permission et verrouillage.
|
||||
"""
|
||||
user = user or current_user
|
||||
return self.etat and self.est_chef_or_diretud(user)
|
||||
|
||||
def can_edit_pv(self, user: User = None):
|
||||
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
|
||||
user = user or current_user
|
||||
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
|
||||
return self.est_chef_or_diretud(user) or user.has_permission(
|
||||
Permission.ScoEtudChangeAdr
|
||||
)
|
||||
|
||||
def annee_scolaire(self) -> int:
|
||||
"""L'année de début de l'année scolaire.
|
||||
Par exemple, 2022 si le semestre va de septebre 2022 à février 2023."""
|
||||
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
|
||||
return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
|
||||
|
||||
def annee_scolaire_str(self):
|
||||
|
@ -459,7 +618,7 @@ class FormSemestre(db.Model):
|
|||
if not imputation_dept:
|
||||
imputation_dept = prefs["DeptName"]
|
||||
imputation_dept = imputation_dept.upper()
|
||||
parcours_name = self.formation.get_parcours().NAME
|
||||
cursus_name = self.formation.get_cursus().NAME
|
||||
modalite = self.modalite
|
||||
# exception pour code Apprentissage:
|
||||
modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
|
||||
|
@ -472,11 +631,13 @@ class FormSemestre(db.Model):
|
|||
scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
|
||||
)
|
||||
return scu.sanitize_string(
|
||||
f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}"
|
||||
f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
|
||||
)
|
||||
|
||||
def titre_annee(self) -> str:
|
||||
""" """
|
||||
"""Le titre avec l'année
|
||||
'DUT Réseaux et Télécommunications semestre 3 FAP 2020-2021'
|
||||
"""
|
||||
titre_annee = (
|
||||
f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}"
|
||||
)
|
||||
|
@ -484,10 +645,12 @@ class FormSemestre(db.Model):
|
|||
titre_annee += "-" + str(self.date_fin.year)
|
||||
return titre_annee
|
||||
|
||||
def titre_formation(self):
|
||||
def titre_formation(self, with_sem_idx=False):
|
||||
"""Titre avec formation, court, pour passerelle: "BUT R&T"
|
||||
(méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
|
||||
"""
|
||||
if with_sem_idx and self.semestre_id > 0:
|
||||
return f"{self.formation.acronyme} S{self.semestre_id}"
|
||||
return self.formation.acronyme
|
||||
|
||||
def titre_mois(self) -> str:
|
||||
|
@ -502,9 +665,9 @@ class FormSemestre(db.Model):
|
|||
|
||||
def titre_num(self) -> str:
|
||||
"""Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
|
||||
if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID:
|
||||
if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
|
||||
return self.titre
|
||||
return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
|
||||
return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
|
||||
|
||||
def sem_modalite(self) -> str:
|
||||
"""Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
|
||||
|
@ -582,14 +745,43 @@ class FormSemestre(db.Model):
|
|||
db.session.add(partition)
|
||||
db.session.flush() # pour avoir un id
|
||||
flash("Partition Parcours créée.")
|
||||
elif partition.groups_editable:
|
||||
# Il ne faut jamais laisser éditer cette partition de parcours
|
||||
partition.groups_editable = False
|
||||
db.session.add(partition)
|
||||
|
||||
for parcour in self.parcours:
|
||||
for parcour in self.get_parcours_apc():
|
||||
if parcour.code:
|
||||
group = GroupDescr.query.filter_by(
|
||||
partition_id=partition.id, group_name=parcour.code
|
||||
).first()
|
||||
if not group:
|
||||
partition.groups.append(GroupDescr(group_name=parcour.code))
|
||||
db.session.flush()
|
||||
# S'il reste des groupes de parcours qui ne sont plus dans le semestre
|
||||
# - s'ils n'ont pas d'inscrits, supprime-les.
|
||||
# - s'ils ont des inscrits: avertissement
|
||||
for group in GroupDescr.query.filter_by(partition_id=partition.id):
|
||||
if group.group_name not in (p.code for p in self.get_parcours_apc()):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
inscr
|
||||
for inscr in self.inscriptions
|
||||
if (inscr.parcour is not None)
|
||||
and inscr.parcour.code == group.group_name
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
flash(f"Suppression du groupe de parcours vide {group.group_name}")
|
||||
db.session.delete(group)
|
||||
else:
|
||||
flash(
|
||||
f"""Attention: groupe de parcours {group.group_name} non vide:
|
||||
réaffectez ses étudiants dans des parcours du semestre"""
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def update_inscriptions_parcours_from_groups(self) -> None:
|
||||
|
@ -648,6 +840,71 @@ class FormSemestre(db.Model):
|
|||
)
|
||||
db.session.commit()
|
||||
|
||||
def etud_validations_description_html(self, etudid: int) -> str:
|
||||
"""Description textuelle des validations de jury de cet étudiant dans ce semestre"""
|
||||
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
||||
|
||||
vals_sem = ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etudid, formsemestre_id=self.id, ue_id=None
|
||||
).all()
|
||||
vals_ues = (
|
||||
ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etudid, formsemestre_id=self.id
|
||||
)
|
||||
.join(UniteEns)
|
||||
.order_by(UniteEns.numero)
|
||||
.all()
|
||||
)
|
||||
# Validations BUT:
|
||||
vals_rcues = (
|
||||
ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id)
|
||||
.join(UniteEns, ApcValidationRCUE.ue1)
|
||||
.order_by(UniteEns.numero)
|
||||
.all()
|
||||
)
|
||||
vals_annee = (
|
||||
ApcValidationAnnee.query.filter_by(
|
||||
etudid=etudid,
|
||||
annee_scolaire=self.annee_scolaire(),
|
||||
)
|
||||
.join(ApcValidationAnnee.formsemestre)
|
||||
.join(FormSemestre.formation)
|
||||
.filter(Formation.formation_code == self.formation.formation_code)
|
||||
.all()
|
||||
)
|
||||
H = []
|
||||
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
|
||||
if vals:
|
||||
H.append(
|
||||
f"""<ul><li>{"</li><li>".join(str(x) for x in vals)}</li></ul>"""
|
||||
)
|
||||
return "\n".join(H)
|
||||
|
||||
def etud_set_all_missing_notes(self, etud: Identite, value=None) -> int:
|
||||
"""Met toutes les notes manquantes de cet étudiant dans ce semestre
|
||||
(ie dans toutes les évaluations des modules auxquels il est inscrit et n'a pas de note)
|
||||
à la valeur donnée par value, qui est en général "ATT", "ABS", "EXC".
|
||||
"""
|
||||
from app.scodoc import sco_saisie_notes
|
||||
|
||||
inscriptions = (
|
||||
ModuleImplInscription.query.filter_by(etudid=etud.id)
|
||||
.join(ModuleImpl)
|
||||
.filter_by(formsemestre_id=self.id)
|
||||
)
|
||||
nb_recorded = 0
|
||||
for inscription in inscriptions:
|
||||
for evaluation in inscription.modimpl.evaluations:
|
||||
if evaluation.get_etud_note(etud) is None:
|
||||
if not sco_saisie_notes.do_evaluation_set_etud_note(
|
||||
evaluation, etud, value
|
||||
):
|
||||
raise ScoValueError(
|
||||
"erreur lors de l'enregistrement de la note"
|
||||
)
|
||||
nb_recorded += 1
|
||||
return nb_recorded
|
||||
|
||||
|
||||
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
|
||||
notes_formsemestre_responsables = db.Table(
|
||||
|
@ -826,7 +1083,9 @@ class FormSemestreInscription(db.Model):
|
|||
# Etape Apogée d'inscription (ajout 2020)
|
||||
etape = db.Column(db.String(APO_CODE_STR_LEN))
|
||||
# Parcours (pour les BUT)
|
||||
parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
|
||||
parcour_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
parcour = db.relationship(ApcParcours)
|
||||
|
||||
def __repr__(self):
|
||||
|
@ -846,8 +1105,8 @@ class NotesSemSet(db.Model):
|
|||
|
||||
title = db.Column(db.Text)
|
||||
annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
|
||||
# periode: 0 (année), 1 (Simpair), 2 (Spair)
|
||||
sem_id = db.Column(db.Integer, nullable=True, default=None)
|
||||
sem_id = db.Column(db.Integer, nullable=False, default=0)
|
||||
"période: 0 (année), 1 (Simpair), 2 (Spair)"
|
||||
|
||||
|
||||
# Association: many to many
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
|
@ -72,7 +72,7 @@ class Partition(db.Model):
|
|||
"""
|
||||
if not isinstance(partition_name, str):
|
||||
return False
|
||||
if not len(partition_name.strip()) > 0:
|
||||
if not (0 < len(partition_name.strip()) < SHORT_STR_LEN):
|
||||
return False
|
||||
if (not existing) and (
|
||||
partition_name in [p.partition_name for p in formsemestre.partitions]
|
||||
|
@ -87,6 +87,7 @@ class Partition(db.Model):
|
|||
def to_dict(self, with_groups=False) -> dict:
|
||||
"""as a dict, with or without groups"""
|
||||
d = dict(self.__dict__)
|
||||
d["partition_id"] = self.id
|
||||
d.pop("_sa_instance_state", None)
|
||||
d.pop("formsemestre", None)
|
||||
|
||||
|
@ -146,7 +147,7 @@ class GroupDescr(db.Model):
|
|||
"""
|
||||
if not isinstance(group_name, str):
|
||||
return False
|
||||
if not default and not len(group_name.strip()) > 0:
|
||||
if not default and not (0 < len(group_name.strip()) < GROUPNAME_STR_LEN):
|
||||
return False
|
||||
if (not existing) and (group_name in [g.group_name for g in partition.groups]):
|
||||
return False
|
||||
|
|
|
@ -5,10 +5,12 @@ import pandas as pd
|
|||
import flask_sqlalchemy
|
||||
|
||||
from app import db
|
||||
from app.auth.models import User
|
||||
from app.comp import df_cache
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.modules import Module
|
||||
|
||||
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -20,14 +22,12 @@ class ModuleImpl(db.Model):
|
|||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
moduleimpl_id = db.synonym("id")
|
||||
module_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_modules.id"),
|
||||
)
|
||||
module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False)
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_formsemestre.id"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
|
||||
# formule de calcul moyenne:
|
||||
|
@ -62,11 +62,11 @@ class ModuleImpl(db.Model):
|
|||
"""Invalide poids cachés"""
|
||||
df_cache.EvaluationsPoidsCache.delete(self.id)
|
||||
|
||||
def check_apc_conformity(self) -> bool:
|
||||
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
|
||||
"""true si les poids des évaluations du module permettent de satisfaire
|
||||
les coefficients du PN.
|
||||
"""
|
||||
if not self.module.formation.get_parcours().APC_SAE or (
|
||||
if not self.module.formation.get_cursus().APC_SAE or (
|
||||
self.module.module_type != scu.ModuleType.RESSOURCE
|
||||
and self.module.module_type != scu.ModuleType.SAE
|
||||
):
|
||||
|
@ -76,7 +76,7 @@ class ModuleImpl(db.Model):
|
|||
return moy_mod.moduleimpl_is_conforme(
|
||||
self,
|
||||
self.get_evaluations_poids(),
|
||||
self.module.formation.get_module_coefs(self.module.semestre_id),
|
||||
res.modimpl_coefs_df,
|
||||
)
|
||||
|
||||
def to_dict(self, convert_objects=False, with_module=True):
|
||||
|
@ -101,6 +101,64 @@ class ModuleImpl(db.Model):
|
|||
d.pop("module", None)
|
||||
return d
|
||||
|
||||
def can_change_ens_by(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)
|
||||
"""
|
||||
if not self.formsemestre.etat:
|
||||
if raise_exc:
|
||||
raise ScoLockedSemError("Modification impossible: semestre verrouille")
|
||||
return False
|
||||
# -- check access
|
||||
# admin ou resp. semestre avec flag resp_can_change_resp
|
||||
if user.has_permission(Permission.ScoImplement):
|
||||
return True
|
||||
if (
|
||||
user.id in [resp.id for resp in self.formsemestre.responsables]
|
||||
) and self.formsemestre.resp_can_change_ens:
|
||||
return True
|
||||
if raise_exc:
|
||||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
|
||||
def est_inscrit(self, etud: Identite) -> bool:
|
||||
"""
|
||||
Vérifie si l'étudiant est bien inscrit au moduleimpl
|
||||
|
||||
Retourne Vrai si c'est le cas, faux sinon
|
||||
"""
|
||||
|
||||
is_module: int = (
|
||||
ModuleImplInscription.query.filter_by(
|
||||
etudid=etud.id, moduleimpl_id=self.id
|
||||
).count()
|
||||
> 0
|
||||
)
|
||||
|
||||
return is_module
|
||||
|
||||
def can_change_ens_by(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)
|
||||
"""
|
||||
if not self.formsemestre.etat:
|
||||
if raise_exc:
|
||||
raise ScoLockedSemError("Modification impossible: semestre verrouille")
|
||||
return False
|
||||
# -- check access
|
||||
# admin ou resp. semestre avec flag resp_can_change_resp
|
||||
if user.has_permission(Permission.ScoImplement):
|
||||
return True
|
||||
if (
|
||||
user.id in [resp.id for resp in self.formsemestre.responsables]
|
||||
) and self.formsemestre.resp_can_change_ens:
|
||||
return True
|
||||
if raise_exc:
|
||||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
|
||||
|
||||
# Enseignants (chargés de TD ou TP) d'un moduleimpl
|
||||
notes_modules_enseignants = db.Table(
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
"""ScoDoc 9 models : Modules
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from app import db
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models.but_refcomp import app_critiques_modules, parcours_modules
|
||||
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_codes_parcours import UE_SPORT
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
|
@ -37,7 +39,9 @@ class Module(db.Model):
|
|||
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
|
||||
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
|
||||
# Relations:
|
||||
modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic")
|
||||
modimpls = db.relationship(
|
||||
"ModuleImpl", backref="module", lazy="dynamic", cascade="all, delete-orphan"
|
||||
)
|
||||
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
|
||||
tags = db.relationship(
|
||||
"NotesTag",
|
||||
|
@ -66,7 +70,39 @@ class Module(db.Model):
|
|||
super(Module, self).__init__(**kwargs)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Module{ModuleType(self.module_type or ModuleType.STANDARD).name} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"
|
||||
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
|
||||
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this module."""
|
||||
mod = Module(
|
||||
titre=self.titre,
|
||||
abbrev=self.abbrev,
|
||||
code=self.code + "-copie",
|
||||
heures_cours=self.heures_cours,
|
||||
heures_td=self.heures_td,
|
||||
heures_tp=self.heures_tp,
|
||||
coefficient=self.coefficient,
|
||||
ects=self.ects,
|
||||
ue_id=self.ue_id,
|
||||
matiere_id=self.matiere_id,
|
||||
formation_id=self.formation_id,
|
||||
semestre_id=self.semestre_id,
|
||||
numero=self.numero, # il est conseillé de renuméroter
|
||||
code_apogee="", # volontairement vide pour éviter les erreurs
|
||||
module_type=self.module_type,
|
||||
)
|
||||
|
||||
# Les tags:
|
||||
for tag in self.tags:
|
||||
mod.tags.append(tag)
|
||||
# Les parcours
|
||||
for parcour in self.parcours:
|
||||
mod.parcours.append(parcour)
|
||||
# Les AC
|
||||
for app_critique in self.app_critiques:
|
||||
mod.app_critiques.append(app_critique)
|
||||
return mod
|
||||
|
||||
def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
|
||||
"""If convert_objects, convert all attributes to native types
|
||||
|
@ -141,6 +177,11 @@ class Module(db.Model):
|
|||
ue_coef_dict = { ue_id : coef }
|
||||
Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée.
|
||||
"""
|
||||
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
||||
current_app.logguer.info(
|
||||
f"set_ue_coef_dict: locked formation, ignoring request"
|
||||
)
|
||||
raise ScoValueError("Formation verrouillée")
|
||||
changed = False
|
||||
for ue_id, coef in ue_coef_dict.items():
|
||||
# Existant ?
|
||||
|
@ -167,6 +208,11 @@ class Module(db.Model):
|
|||
|
||||
def update_ue_coef_dict(self, ue_coef_dict: dict):
|
||||
"""update coefs vers UE (ajoute aux existants)"""
|
||||
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
||||
current_app.logguer.info(
|
||||
f"update_ue_coef_dict: locked formation, ignoring request"
|
||||
)
|
||||
raise ScoValueError("Formation verrouillée")
|
||||
current = self.get_ue_coef_dict()
|
||||
current.update(ue_coef_dict)
|
||||
self.set_ue_coef_dict(current)
|
||||
|
@ -175,8 +221,17 @@ class Module(db.Model):
|
|||
"""returns { ue_id : coef }"""
|
||||
return {p.ue.id: p.coef for p in self.ue_coefs}
|
||||
|
||||
def get_ue_coef_dict_acronyme(self):
|
||||
"""returns { ue_acronyme : coef }"""
|
||||
return {p.ue.acronyme: p.coef for p in self.ue_coefs}
|
||||
|
||||
def delete_ue_coef(self, ue):
|
||||
"""delete coef"""
|
||||
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
||||
current_app.logguer.info(
|
||||
f"delete_ue_coef: locked formation, ignoring request"
|
||||
)
|
||||
raise ScoValueError("Formation verrouillée")
|
||||
ue_coef = ModuleUECoef.query.get((self.id, ue.id))
|
||||
if ue_coef:
|
||||
db.session.delete(ue_coef)
|
||||
|
@ -188,25 +243,31 @@ class Module(db.Model):
|
|||
# à redéfinir les relationships...
|
||||
return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
|
||||
|
||||
def ue_coefs_list(self, include_zeros=True):
|
||||
def ue_coefs_list(
|
||||
self, include_zeros=True, ues: list["UniteEns"] = None
|
||||
) -> list[tuple["UniteEns", float]]:
|
||||
"""Liste des coefs vers les UE (pour les modules APC).
|
||||
Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
|
||||
Si ues est spécifié, restreint aux UE indiquées.
|
||||
Sinon si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
|
||||
sauf UE bonus sport.
|
||||
Result: List of tuples [ (ue, coef) ]
|
||||
"""
|
||||
if not self.is_apc():
|
||||
return []
|
||||
if include_zeros:
|
||||
if include_zeros and ues is None:
|
||||
# Toutes les UE du même semestre:
|
||||
ues_semestre = (
|
||||
ues = (
|
||||
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
|
||||
.filter(UniteEns.type != UE_SPORT)
|
||||
.order_by(UniteEns.numero)
|
||||
.all()
|
||||
)
|
||||
if not ues:
|
||||
return []
|
||||
if ues:
|
||||
coefs_dict = self.get_ue_coef_dict()
|
||||
coefs_list = []
|
||||
for ue in ues_semestre:
|
||||
for ue in ues:
|
||||
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
|
||||
return coefs_list
|
||||
# Liste seulement les coefs définis:
|
||||
|
@ -218,6 +279,19 @@ class Module(db.Model):
|
|||
return {x.strip() for x in self.code_apogee.split(",") if x}
|
||||
return set()
|
||||
|
||||
def get_parcours(self) -> list[ApcParcours]:
|
||||
"""Les parcours utilisant ce module.
|
||||
Si tous les parcours, liste vide (!).
|
||||
"""
|
||||
ref_comp = self.formation.referentiel_competence
|
||||
if not ref_comp:
|
||||
return []
|
||||
tous_parcours_ids = {p.id for p in ref_comp.parcours}
|
||||
parcours_ids = {p.id for p in self.parcours}
|
||||
if tous_parcours_ids == parcours_ids:
|
||||
return []
|
||||
return self.parcours
|
||||
|
||||
|
||||
class ModuleUECoef(db.Model):
|
||||
"""Coefficients des modules vers les UE (APC, BUT)
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
"""
|
||||
|
||||
from app import db
|
||||
|
||||
import app.scodoc.notesdb as ndb
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
|
@ -53,6 +51,13 @@ class NotesNotes(db.Model):
|
|||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
|
||||
def __repr__(self):
|
||||
"pour debug"
|
||||
from app.models.evaluations import Evaluation
|
||||
|
||||
return f"""<{self.__class__.__name__} {self.id} v={self.value} {self.date.isoformat()
|
||||
} {Evaluation.query.get(self.evaluation_id) if self.evaluation_id else "X" }>"""
|
||||
|
||||
|
||||
class NotesNotesLog(db.Model):
|
||||
"""Historique des modifs sur notes (anciennes entrees de notes_notes)"""
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
"""ScoDoc 9 models : Unités d'Enseignement (UE)
|
||||
"""
|
||||
|
||||
from app import db
|
||||
import pandas as pd
|
||||
|
||||
from app import db, log
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.but_refcomp import ApcNiveau, ApcParcours
|
||||
from app.models.modules import Module
|
||||
from app.scodoc.sco_exceptions import ScoFormationConflict
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -46,12 +51,26 @@ class UniteEns(db.Model):
|
|||
color = db.Column(db.Text())
|
||||
|
||||
# BUT
|
||||
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
|
||||
niveau_competence_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="SET NULL")
|
||||
)
|
||||
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
|
||||
|
||||
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul:
|
||||
parcour_id = db.Column(
|
||||
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
parcour = db.relationship("ApcParcours", back_populates="ues")
|
||||
|
||||
# relations
|
||||
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
|
||||
modules = db.relationship("Module", lazy="dynamic", backref="ue")
|
||||
dispense_ues = db.relationship(
|
||||
"DispenseUE",
|
||||
back_populates="ue",
|
||||
cascade="all, delete",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
|
||||
|
@ -59,6 +78,28 @@ class UniteEns(db.Model):
|
|||
self.semestre_idx} {
|
||||
'EXTERNE' if self.is_external else ''})>"""
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this ue.
|
||||
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
|
||||
(parcours et niveau).
|
||||
"""
|
||||
ue = UniteEns(
|
||||
formation_id=self.formation_id,
|
||||
acronyme=self.acronyme + "-copie",
|
||||
numero=self.numero,
|
||||
titre=self.titre,
|
||||
semestre_idx=self.semestre_idx,
|
||||
type=self.type,
|
||||
ue_code="", # ne duplique pas le code
|
||||
ects=self.ects,
|
||||
is_external=self.is_external,
|
||||
code_apogee="", # ne copie pas les codes Apo
|
||||
coefficient=self.coefficient,
|
||||
coef_rcue=self.coef_rcue,
|
||||
color=self.color,
|
||||
)
|
||||
return ue
|
||||
|
||||
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
|
||||
"""as a dict, with the same conversions as in ScoDoc7
|
||||
(except ECTS: keep None)
|
||||
|
@ -74,6 +115,7 @@ class UniteEns(db.Model):
|
|||
e["ects"] = e["ects"]
|
||||
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
|
||||
e["code_apogee"] = e["code_apogee"] or "" # pas de None
|
||||
e["parcour"] = self.parcour.to_dict() if self.parcour else None
|
||||
if with_module_ue_coefs:
|
||||
if convert_objects:
|
||||
e["module_ue_coefs"] = [
|
||||
|
@ -83,6 +125,12 @@ class UniteEns(db.Model):
|
|||
e.pop("module_ue_coefs", None)
|
||||
return e
|
||||
|
||||
def annee(self) -> int:
|
||||
"""L'année dans la formation (commence à 1).
|
||||
En APC seulement, en classic renvoie toujours 1.
|
||||
"""
|
||||
return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
|
||||
|
||||
def is_locked(self):
|
||||
"""True if UE should not be modified
|
||||
(contains modules used in a locked formsemestre)
|
||||
|
@ -135,3 +183,137 @@ class UniteEns(db.Model):
|
|||
if self.code_apogee:
|
||||
return {x.strip() for x in self.code_apogee.split(",") if x}
|
||||
return set()
|
||||
|
||||
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
|
||||
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
|
||||
# Les UE du même semestre que nous:
|
||||
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
|
||||
if (new_niveau_id, new_parcour_id) in (
|
||||
(oue.niveau_competence_id, oue.parcour_id)
|
||||
for oue in ues_sem
|
||||
if oue.id != self.id
|
||||
):
|
||||
log(
|
||||
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
|
||||
)
|
||||
raise ScoFormationConflict()
|
||||
|
||||
def set_niveau_competence(self, niveau: ApcNiveau):
|
||||
"""Associe cette UE au niveau de compétence indiqué.
|
||||
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
|
||||
Assure que ce soit la seule dans son parcours.
|
||||
Sinon, raises ScoFormationConflict.
|
||||
|
||||
Si niveau est None, désassocie.
|
||||
"""
|
||||
if niveau is not None:
|
||||
self._check_apc_conflict(niveau.id, self.parcour_id)
|
||||
# Le niveau est-il dans le parcours ? Sinon, erreur
|
||||
if self.parcour and niveau.id not in (
|
||||
n.id
|
||||
for n in niveau.niveaux_annee_de_parcours(
|
||||
self.parcour, self.annee(), self.formation.referentiel_competence
|
||||
)
|
||||
):
|
||||
log(
|
||||
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
|
||||
)
|
||||
return
|
||||
|
||||
self.niveau_competence = niveau
|
||||
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
# Invalidation du cache
|
||||
self.formation.invalidate_cached_sems()
|
||||
log(f"ue.set_niveau_competence( {self}, {niveau} )")
|
||||
|
||||
def set_parcour(self, parcour: ApcParcours):
|
||||
"""Associe cette UE au parcours indiqué.
|
||||
Assure que ce soit la seule dans son parcours.
|
||||
Sinon, raises ScoFormationConflict.
|
||||
|
||||
Si niveau est None, désassocie.
|
||||
"""
|
||||
if (parcour is not None) and self.niveau_competence is not None:
|
||||
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
|
||||
self.parcour = parcour
|
||||
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
|
||||
if (
|
||||
parcour
|
||||
and self.niveau_competence
|
||||
and self.niveau_competence.id
|
||||
not in (
|
||||
n.id
|
||||
for n in self.niveau_competence.niveaux_annee_de_parcours(
|
||||
parcour, self.annee(), self.formation.referentiel_competence
|
||||
)
|
||||
)
|
||||
):
|
||||
self.niveau_competence = None
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
# Invalidation du cache
|
||||
self.formation.invalidate_cached_sems()
|
||||
log(f"ue.set_parcour( {self}, {parcour} )")
|
||||
|
||||
|
||||
class DispenseUE(db.Model):
|
||||
"""Dispense d'UE
|
||||
Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
|
||||
qu'ils ne refont pas.
|
||||
La dispense d'UE n'est PAS une validation:
|
||||
- elle n'est pas affectée par les décisions de jury (pas effacée)
|
||||
- elle est associée à un formsemestre
|
||||
- elle ne permet pas la délivrance d'ECTS ou du diplôme.
|
||||
|
||||
On utilise cette dispense et non une "inscription" par souci d'efficacité:
|
||||
en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours,
|
||||
la dispense étant une exception.
|
||||
"""
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
formsemestre_id = formsemestre_id = db.Column(
|
||||
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
|
||||
)
|
||||
ue_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
ue = db.relationship("UniteEns", back_populates="dispense_ues")
|
||||
etudid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
etud = db.relationship("Identite", back_populates="dispense_ues")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"""<{self.__class__.__name__} {self.id} etud={
|
||||
repr(self.etud)} ue={repr(self.ue)}>"""
|
||||
|
||||
@classmethod
|
||||
def load_formsemestre_dispense_ues_set(
|
||||
cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns]
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Construit l'ensemble des
|
||||
etudids = modimpl_inscr_df.index, # les etudids
|
||||
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
|
||||
|
||||
Résultat: set de (etudid, ue_id).
|
||||
"""
|
||||
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
|
||||
# puis filtre sur inscrits et ues
|
||||
ue_ids = {ue.id for ue in ues}
|
||||
dispense_ues = {
|
||||
(dispense_ue.etudid, dispense_ue.ue_id)
|
||||
for dispense_ue in DispenseUE.query.filter_by(
|
||||
formsemestre_id=formsemestre.id
|
||||
)
|
||||
if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids
|
||||
}
|
||||
return dispense_ues
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
|
||||
from app import db
|
||||
from app import log
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models import CODE_STR_LEN
|
||||
from app.models.events import Scolog
|
||||
|
@ -53,11 +54,21 @@ class ScolarFormSemestreValidation(db.Model):
|
|||
)
|
||||
|
||||
ue = db.relationship("UniteEns", lazy="select", uselist=False)
|
||||
formsemestre = db.relationship(
|
||||
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
|
||||
return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
|
||||
|
||||
def __str__(self):
|
||||
if self.ue_id:
|
||||
# Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue !
|
||||
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}"""
|
||||
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"as a dict"
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
|
@ -83,7 +94,12 @@ class ScolarAutorisationInscription(db.Model):
|
|||
db.ForeignKey("notes_formsemestre.id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"""{self.__class__.__name__}(id={self.id}, etudid={
|
||||
self.etudid}, semestre_id={self.semestre_id})"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"as a dict"
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
|
@ -96,8 +112,7 @@ class ScolarAutorisationInscription(db.Model):
|
|||
origin_formsemestre_id: int,
|
||||
semestre_id: int,
|
||||
):
|
||||
"""Enregistre une autorisation, remplace celle émanant du même semestre si elle existe."""
|
||||
cls.delete_autorisation_etud(etudid, origin_formsemestre_id)
|
||||
"""Ajoute une autorisation"""
|
||||
autorisation = cls(
|
||||
etudid=etudid,
|
||||
formation_code=formation_code,
|
||||
|
@ -105,7 +120,10 @@ class ScolarAutorisationInscription(db.Model):
|
|||
semestre_id=semestre_id,
|
||||
)
|
||||
db.session.add(autorisation)
|
||||
Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}")
|
||||
Scolog.logdb(
|
||||
"autorise_etud", etudid=etudid, msg=f"Passage vers S{semestre_id}: autorisé"
|
||||
)
|
||||
log(f"ScolarAutorisationInscription: recording {autorisation}")
|
||||
|
||||
@classmethod
|
||||
def delete_autorisation_etud(
|
||||
|
@ -113,16 +131,17 @@ class ScolarAutorisationInscription(db.Model):
|
|||
etudid: int,
|
||||
origin_formsemestre_id: int,
|
||||
):
|
||||
"""Efface les autorisations de cette étudiant venant du sem. origine"""
|
||||
"""Efface les autorisations de cet étudiant venant du sem. origine"""
|
||||
autorisations = cls.query.filter_by(
|
||||
etudid=etudid, origin_formsemestre_id=origin_formsemestre_id
|
||||
)
|
||||
for autorisation in autorisations:
|
||||
db.session.delete(autorisation)
|
||||
log(f"ScolarAutorisationInscription: deleting {autorisation}")
|
||||
Scolog.logdb(
|
||||
"autorise_etud",
|
||||
etudid=etudid,
|
||||
msg=f"annule passage vers S{autorisation.semestre_id}",
|
||||
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
|
@ -140,11 +159,11 @@ class ScolarEvent(db.Model):
|
|||
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
formsemestre_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_formsemestre.id"),
|
||||
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
|
||||
)
|
||||
ue_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("notes_ue.id"),
|
||||
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
|
||||
)
|
||||
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
|
||||
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
|
||||
|
@ -156,8 +175,16 @@ class ScolarEvent(db.Model):
|
|||
db.Integer,
|
||||
db.ForeignKey("notes_formsemestre.id"),
|
||||
)
|
||||
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
|
||||
formsemestre = db.relationship(
|
||||
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"as a dict"
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -253,7 +253,7 @@ def get_annotation_PE(etudid, tag_annotation_pe):
|
|||
) # Suppression du tag d'annotation PE
|
||||
annotationPE = annotationPE.replace("\r", "") # Suppression des \r
|
||||
annotationPE = annotationPE.replace(
|
||||
"<br/>", "\n\n"
|
||||
"<br>", "\n\n"
|
||||
) # Interprète les retours chariots html
|
||||
return annotationPE
|
||||
return "" # pas d'annotations
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -48,11 +48,11 @@ from zipfile import ZipFile
|
|||
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_compat import NotesTableCompat
|
||||
from app.models import FormSemestre
|
||||
from app.models import Formation, FormSemestre
|
||||
|
||||
from app.scodoc.gen_tables import GenTable, SeqGenTable
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc import sco_codes_parcours # sco_codes_parcours.NEXT -> sem suivant
|
||||
from app.scodoc import codes_cursus # codes_cursus.NEXT -> sem suivant
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_formsemestre
|
||||
from app.pe import pe_tagtable
|
||||
|
@ -65,10 +65,8 @@ def comp_nom_semestre_dans_parcours(sem):
|
|||
"""Le nom a afficher pour titrer un semestre
|
||||
par exemple: "semestre 2 FI 2015"
|
||||
"""
|
||||
from app.scodoc import sco_formations
|
||||
|
||||
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
|
||||
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
|
||||
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
|
||||
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
|
||||
return "%s %s %s %s" % (
|
||||
parcours.SESSION_NAME, # eg "semestre"
|
||||
sem["semestre_id"], # eg 2
|
||||
|
@ -457,10 +455,9 @@ class JuryPE(object):
|
|||
|
||||
reponse = False
|
||||
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
|
||||
(_, parcours) = sco_report.get_codeparcoursetud(etud)
|
||||
(_, parcours) = sco_report.get_code_cursus_etud(etud)
|
||||
if (
|
||||
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
|
||||
> 0
|
||||
len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0
|
||||
): # Eliminé car NAR apparait dans le parcours
|
||||
reponse = True
|
||||
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
|
||||
|
@ -529,14 +526,14 @@ class JuryPE(object):
|
|||
from app.scodoc import sco_report
|
||||
|
||||
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
|
||||
(code, parcours) = sco_report.get_codeparcoursetud(
|
||||
(code, parcours) = sco_report.get_code_cursus_etud(
|
||||
etud
|
||||
) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...}
|
||||
sonDernierSemestreValide = max(
|
||||
[
|
||||
int(cle)
|
||||
for (cle, code) in parcours.items()
|
||||
if code in sco_codes_parcours.CODES_SEM_VALIDES
|
||||
if code in codes_cursus.CODES_SEM_VALIDES
|
||||
]
|
||||
+ [0]
|
||||
) # n° du dernier semestre valide, 0 sinon
|
||||
|
@ -563,9 +560,8 @@ class JuryPE(object):
|
|||
dec = nt.get_etud_decision_sem(
|
||||
etudid
|
||||
) # quelle est la décision du jury ?
|
||||
if dec and dec["code"] in list(
|
||||
sco_codes_parcours.CODES_SEM_VALIDES.keys()
|
||||
): # isinstance( sesMoyennes[i+1], float) and
|
||||
if dec and (dec["code"] in codes_cursus.CODES_SEM_VALIDES):
|
||||
# isinstance( sesMoyennes[i+1], float) and
|
||||
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
|
||||
leFid = sem["formsemestre_id"]
|
||||
else:
|
||||
|
@ -1139,7 +1135,7 @@ class JuryPE(object):
|
|||
# ------------------------------------------------------------------------------------------------------------------
|
||||
def get_cache_notes_d_un_semestre(self, formsemestre_id: int) -> NotesTableCompat:
|
||||
"""Charge la table des notes d'un formsemestre"""
|
||||
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
return res_sem.load_formsemestre_results(formsemestre)
|
||||
|
||||
# ------------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -41,11 +41,12 @@ from app.comp import res_sem
|
|||
from app.comp.res_compat import NotesTableCompat
|
||||
from app.models import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
|
||||
from app.scodoc import sco_codes_parcours
|
||||
from app.scodoc import sco_tag_module
|
||||
from app.pe import pe_tagtable
|
||||
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_tag_module
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class SemestreTag(pe_tagtable.TableTag):
|
||||
"""Un SemestreTag représente un tableau de notes (basé sur notesTable)
|
||||
|
@ -103,7 +104,7 @@ class SemestreTag(pe_tagtable.TableTag):
|
|||
self.inscrlist = [
|
||||
etud
|
||||
for etud in self.nt.inscrlist
|
||||
if self.nt.get_etud_etat(etud["etudid"]) == "I"
|
||||
if self.nt.get_etud_etat(etud["etudid"]) == scu.INSCRIT
|
||||
]
|
||||
self.identdict = {
|
||||
etudid: ident
|
||||
|
@ -115,7 +116,7 @@ class SemestreTag(pe_tagtable.TableTag):
|
|||
self.modimpls = [
|
||||
modimpl
|
||||
for modimpl in self.nt.formsemestre.modimpls_sorted
|
||||
if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD
|
||||
if modimpl.module.ue.type == codes_cursus.UE_STANDARD
|
||||
] # la liste des modules (objet modimpl)
|
||||
self.somme_coeffs = sum(
|
||||
[
|
||||
|
@ -255,7 +256,7 @@ class SemestreTag(pe_tagtable.TableTag):
|
|||
# Si le module ne fait pas partie des UE capitalisées
|
||||
if modimpl.module.ue.id not in ue_capitalisees_id:
|
||||
note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note
|
||||
coeff = modimpl.module.coefficient # le coeff
|
||||
coeff = modimpl.module.coefficient or 0.0 # le coeff (! non compatible BUT)
|
||||
coeff_norm = (
|
||||
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
|
||||
) # le coeff normalisé
|
||||
|
@ -276,7 +277,7 @@ class SemestreTag(pe_tagtable.TableTag):
|
|||
fid_prec = fids_prec[0]
|
||||
# Lecture des notes de ce semestre
|
||||
# le tableau de note du semestre considéré:
|
||||
formsemestre_prec = FormSemestre.query.get_or_404(fid_prec)
|
||||
formsemestre_prec = FormSemestre.get_formsemestre(fid_prec)
|
||||
nt_prec: NotesTableCompat = res_sem.load_formsemestre_results(
|
||||
formsemestre_prec
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -40,7 +40,7 @@ Created on Thu Sep 8 09:36:33 2016
|
|||
import datetime
|
||||
import numpy as np
|
||||
|
||||
from app.scodoc import notes_table
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class TableTag(object):
|
||||
|
@ -186,7 +186,7 @@ class TableTag(object):
|
|||
if isinstance(col[0], float)
|
||||
else 0, # remplace les None et autres chaines par des zéros
|
||||
) # triées
|
||||
self.rangs[tag] = notes_table.comp_ranks(lesMoyennesTriees) # les rangs
|
||||
self.rangs[tag] = scu.comp_ranks(lesMoyennesTriees) # les rangs
|
||||
|
||||
# calcul des stats
|
||||
self.comp_stats_d_un_tag(tag)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -55,7 +55,7 @@ def _pe_view_sem_recap_form(formsemestre_id):
|
|||
<p class="help">
|
||||
Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
|
||||
poursuites d'études.
|
||||
<br/>
|
||||
<br>
|
||||
De nombreux aspects sont paramétrables:
|
||||
<a href="https://scodoc.org/AvisPoursuiteEtudes" target="_blank" rel="noopener">
|
||||
voir la documentation</a>.
|
||||
|
@ -65,7 +65,7 @@ def _pe_view_sem_recap_form(formsemestre_id):
|
|||
<div class="pe_template_up">
|
||||
Les templates sont généralement installés sur le serveur ou dans le
|
||||
paramétrage de ScoDoc.
|
||||
<br/>
|
||||
<br>
|
||||
Au besoin, vous pouvez spécifier ici votre propre fichier de template
|
||||
(<tt>un_avis.tex</tt>):
|
||||
<div class="pe_template_upb">Template:
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
from time import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Profiler:
|
||||
OUTPUT: str = "/tmp/scodoc.profiler.csv"
|
||||
|
||||
def __init__(self, tag: str) -> None:
|
||||
self.tag: str = tag
|
||||
self.start_time: time = None
|
||||
self.stop_time: time = None
|
||||
|
||||
def start(self):
|
||||
self.start_time = time()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self.stop_time = time()
|
||||
return self
|
||||
|
||||
def elapsed(self) -> float:
|
||||
return self.stop_time - self.start_time
|
||||
|
||||
def dates(self) -> tuple[datetime, datetime]:
|
||||
return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp(
|
||||
self.stop_time
|
||||
)
|
||||
|
||||
def write(self):
|
||||
with open(Profiler.OUTPUT, "a") as file:
|
||||
dates: tuple = self.dates()
|
||||
date_str = (dates[0].isoformat(), dates[1].isoformat())
|
||||
file.write(f"\n{self.tag},{self.elapsed() : .2}")
|
||||
|
||||
@classmethod
|
||||
def write_in(cls, msg: str):
|
||||
with open(cls.OUTPUT, "a") as file:
|
||||
file.write(f"\n# {msg}")
|
||||
|
||||
@classmethod
|
||||
def clear(cls):
|
||||
with open(cls.OUTPUT, "w") as file:
|
||||
file.write("")
|
|
@ -10,6 +10,11 @@
|
|||
"""
|
||||
import html
|
||||
import re
|
||||
|
||||
import flask_wtf
|
||||
import wtforms
|
||||
from app import log
|
||||
from app.scodoc.sco_exceptions import ScoInvalidCSRF
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
# re validant dd/mm/yyyy
|
||||
|
@ -22,7 +27,7 @@ def TrivialFormulator(
|
|||
form_url,
|
||||
values,
|
||||
formdescription=(),
|
||||
initvalues={},
|
||||
initvalues=None,
|
||||
method="post",
|
||||
enctype=None,
|
||||
submitlabel="OK",
|
||||
|
@ -32,12 +37,15 @@ def TrivialFormulator(
|
|||
cssclass="",
|
||||
cancelbutton=None,
|
||||
submitbutton=True,
|
||||
submitbuttonattributes=[],
|
||||
submitbuttonattributes=None,
|
||||
top_buttons=False, # place buttons at top of form
|
||||
bottom_buttons=True, # buttons after form
|
||||
html_foot_markup="",
|
||||
readonly=False,
|
||||
is_submitted=False,
|
||||
title="",
|
||||
after_table="",
|
||||
before_table="{title}",
|
||||
):
|
||||
"""
|
||||
form_url : URL for this form
|
||||
|
@ -74,7 +82,8 @@ def TrivialFormulator(
|
|||
HTML elements:
|
||||
input_type : 'text', 'textarea', 'password',
|
||||
'radio', 'menu', 'checkbox',
|
||||
'hidden', 'separator', 'file', 'date', 'datedmy' (avec validation),
|
||||
'hidden', 'separator', 'table_separator',
|
||||
'file', 'date', 'datedmy' (avec validation),
|
||||
'boolcheckbox', 'text_suggest',
|
||||
'color'
|
||||
(default text)
|
||||
|
@ -95,7 +104,7 @@ def TrivialFormulator(
|
|||
form_url,
|
||||
values,
|
||||
formdescription,
|
||||
initvalues,
|
||||
initvalues or {},
|
||||
method,
|
||||
enctype,
|
||||
submitlabel,
|
||||
|
@ -105,12 +114,15 @@ def TrivialFormulator(
|
|||
cssclass=cssclass,
|
||||
cancelbutton=cancelbutton,
|
||||
submitbutton=submitbutton,
|
||||
submitbuttonattributes=submitbuttonattributes,
|
||||
submitbuttonattributes=submitbuttonattributes or [],
|
||||
top_buttons=top_buttons,
|
||||
bottom_buttons=bottom_buttons,
|
||||
html_foot_markup=html_foot_markup,
|
||||
readonly=readonly,
|
||||
is_submitted=is_submitted,
|
||||
title=title,
|
||||
after_table=after_table,
|
||||
before_table=before_table,
|
||||
)
|
||||
form = t.getform()
|
||||
if t.canceled():
|
||||
|
@ -127,8 +139,8 @@ class TF(object):
|
|||
self,
|
||||
form_url,
|
||||
values,
|
||||
formdescription=[],
|
||||
initvalues={},
|
||||
formdescription=None,
|
||||
initvalues=None,
|
||||
method="POST",
|
||||
enctype=None,
|
||||
submitlabel="OK",
|
||||
|
@ -138,17 +150,20 @@ class TF(object):
|
|||
cssclass="",
|
||||
cancelbutton=None,
|
||||
submitbutton=True,
|
||||
submitbuttonattributes=[],
|
||||
submitbuttonattributes=None,
|
||||
top_buttons=False, # place buttons at top of form
|
||||
bottom_buttons=True, # buttons after form
|
||||
html_foot_markup="", # html snippet put at the end, just after the table
|
||||
readonly=False,
|
||||
is_submitted=False,
|
||||
title="",
|
||||
after_table="",
|
||||
before_table="{title}",
|
||||
):
|
||||
self.form_url = form_url
|
||||
self.values = values.copy()
|
||||
self.formdescription = list(formdescription)
|
||||
self.initvalues = initvalues
|
||||
self.formdescription = list(formdescription or [])
|
||||
self.initvalues = initvalues or {}
|
||||
self.method = method
|
||||
self.enctype = enctype
|
||||
self.submitlabel = submitlabel
|
||||
|
@ -161,10 +176,13 @@ class TF(object):
|
|||
self.cssclass = cssclass
|
||||
self.cancelbutton = cancelbutton
|
||||
self.submitbutton = submitbutton
|
||||
self.submitbuttonattributes = submitbuttonattributes
|
||||
self.submitbuttonattributes = submitbuttonattributes or []
|
||||
self.top_buttons = top_buttons
|
||||
self.bottom_buttons = bottom_buttons
|
||||
self.html_foot_markup = html_foot_markup
|
||||
self.title = title
|
||||
self.after_table = after_table
|
||||
self.before_table = before_table
|
||||
self.readonly = readonly
|
||||
self.result = None
|
||||
self.is_submitted = is_submitted
|
||||
|
@ -176,11 +194,26 @@ class TF(object):
|
|||
"true if form has been submitted"
|
||||
if self.is_submitted:
|
||||
return True
|
||||
return self.values.get("%s_submitted" % self.formid, False)
|
||||
form_submitted = self.values.get(f"{self.formid}_submitted", False)
|
||||
if form_submitted:
|
||||
self.check_csrf()
|
||||
return form_submitted
|
||||
|
||||
def check_csrf(self):
|
||||
"""check token for POST forms.
|
||||
Raises ScoInvalidCSRF on failure.
|
||||
"""
|
||||
if self.method == "post":
|
||||
token = self.values.get("csrf_token")
|
||||
try:
|
||||
flask_wtf.csrf.validate_csrf(token)
|
||||
except wtforms.validators.ValidationError as exc:
|
||||
log(f"Form.check_csrf: invalid CSRF token\n{exc.args}")
|
||||
raise ScoInvalidCSRF() from exc
|
||||
|
||||
def canceled(self):
|
||||
"true if form has been canceled"
|
||||
return self.values.get("%s_cancel" % self.formid, False)
|
||||
return self.values.get(f"{self.formid}_cancel", False)
|
||||
|
||||
def getform(self):
|
||||
"return HTML form"
|
||||
|
@ -357,12 +390,23 @@ class TF(object):
|
|||
self.values[field] = True
|
||||
else:
|
||||
self.values[field] = False
|
||||
# open('/tmp/toto','a').write('checkvalues: val=%s (%s) values[%s] = %s\n' % (val, type(val), field, self.values[field]))
|
||||
if descr.get("convert_numbers", False):
|
||||
if typ[:3] == "int":
|
||||
self.values[field] = int(self.values[field])
|
||||
try:
|
||||
self.values[field] = int(self.values[field])
|
||||
except ValueError:
|
||||
msg.append(
|
||||
f"valeur invalide ({self.values[field]}) pour le champs {field}"
|
||||
)
|
||||
ok = False
|
||||
elif typ == "float" or typ == "real":
|
||||
self.values[field] = float(self.values[field].replace(",", "."))
|
||||
try:
|
||||
self.values[field] = float(self.values[field].replace(",", "."))
|
||||
except ValueError:
|
||||
msg.append(
|
||||
f"valeur invalide ({self.values[field]}) pour le champs {field}"
|
||||
)
|
||||
ok = False
|
||||
if ok:
|
||||
self.result = self.values
|
||||
else:
|
||||
|
@ -423,9 +467,16 @@ class TF(object):
|
|||
self.form_attrs,
|
||||
)
|
||||
)
|
||||
R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid)
|
||||
if self.method == "post":
|
||||
R.append(
|
||||
f"""<input type="hidden" name="csrf_token" value="{
|
||||
flask_wtf.csrf.generate_csrf()
|
||||
}">"""
|
||||
)
|
||||
R.append(f"""<input type="hidden" name="{self.formid}_submitted" value="1">""")
|
||||
if self.top_buttons:
|
||||
R.append(buttons_markup + "<p></p>")
|
||||
R.append(self.before_table.format(title=self.title))
|
||||
R.append('<table class="tf">')
|
||||
for field, descr in self.formdescription:
|
||||
if descr.get("readonly", False):
|
||||
|
@ -453,6 +504,16 @@ class TF(object):
|
|||
etempl = separatortemplate
|
||||
R.append(etempl % {"label": title, "item_dom_attr": item_dom_attr})
|
||||
continue
|
||||
elif input_type == "table_separator":
|
||||
etempl = ""
|
||||
# Table ouverte ?
|
||||
if len([p for p in R if "<table" in p]) > len(
|
||||
[p for p in R if "</table" in p]
|
||||
):
|
||||
R.append(f"""</table>{self.after_table}""")
|
||||
R.append(
|
||||
f"""{self.before_table.format(title=descr.get("title", ""))}<table class="tf">"""
|
||||
)
|
||||
else:
|
||||
etempl = itemtemplate
|
||||
lab = []
|
||||
|
@ -543,11 +604,8 @@ class TF(object):
|
|||
disabled_items = descr.get("disabled_items", {})
|
||||
if vertical:
|
||||
lem.append("<table>")
|
||||
for i in range(len(labels)):
|
||||
for i in range(len(labels)): # pylint: disable=consider-using-enumerate
|
||||
if input_type == "checkbox":
|
||||
# from app.scodoc.sco_utils import log # debug only
|
||||
# log('checkbox: values[%s] = "%s"' % (field,repr(values[field]) ))
|
||||
# log("descr['allowed_values'][%s] = '%s'" % (i, repr(descr['allowed_values'][i])))
|
||||
if (
|
||||
values[field]
|
||||
and descr["allowed_values"][i] in values[field]
|
||||
|
@ -563,7 +621,7 @@ class TF(object):
|
|||
else:
|
||||
try:
|
||||
v = int(values[field])
|
||||
except:
|
||||
except (ValueError, KeyError):
|
||||
v = False
|
||||
if v:
|
||||
checked = 'checked="checked"'
|
||||
|
@ -613,7 +671,7 @@ class TF(object):
|
|||
'<input type="hidden" name="%s" id="%s" value="%s" %s >'
|
||||
% (field, wid, values[field], attribs)
|
||||
)
|
||||
elif input_type == "separator":
|
||||
elif (input_type == "separator") or (input_type == "table_separator"):
|
||||
pass
|
||||
elif input_type == "file":
|
||||
lem.append(
|
||||
|
@ -644,13 +702,15 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
|
|||
)
|
||||
lem.append(('value="%(' + field + ')s" >') % values)
|
||||
else:
|
||||
raise ValueError("unkown input_type for form (%s)!" % input_type)
|
||||
raise ValueError(f"unkown input_type for form ({input_type})!")
|
||||
explanation = descr.get("explanation", "")
|
||||
if explanation:
|
||||
lem.append('<span class="tf-explanation">%s</span>' % explanation)
|
||||
lem.append(f"""<span class="tf-explanation">{explanation}</span>""")
|
||||
comment = descr.get("comment", "")
|
||||
if comment:
|
||||
lem.append('<br/><span class="tf-comment">%s</span>' % comment)
|
||||
if (input_type != "checkbox") and (input_type != "boolcheckbox"):
|
||||
lem.append("<br>")
|
||||
lem.append(f"""<span class="tf-comment">{comment}</span>""")
|
||||
R.append(
|
||||
etempl
|
||||
% {
|
||||
|
@ -660,11 +720,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
|
|||
}
|
||||
)
|
||||
R.append("</table>")
|
||||
|
||||
R.append(self.after_table)
|
||||
R.append(self.html_foot_markup)
|
||||
|
||||
if self.bottom_buttons:
|
||||
R.append("<br/>" + buttons_markup)
|
||||
R.append("<br>" + buttons_markup)
|
||||
|
||||
if add_no_enter_js:
|
||||
R.append(
|
||||
|
@ -756,7 +816,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
|
|||
|
||||
if input_type == "separator": # separator
|
||||
R.append('<td colspan="2">%s' % title)
|
||||
else:
|
||||
elif input_type != "table_separator":
|
||||
R.append('<td class="tf-ro-fieldlabel%s">' % klass)
|
||||
R.append("%s</td>" % title)
|
||||
R.append('<td class="tf-ro-field%s">' % klass)
|
||||
|
@ -766,7 +826,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
|
|||
elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"):
|
||||
if input_type == "boolcheckbox":
|
||||
labels = descr.get(
|
||||
"labels", descr.get("allowed_values", ["oui", "non"])
|
||||
"labels", descr.get("allowed_values", ["non", "oui"])
|
||||
)
|
||||
_val = self.values[field]
|
||||
if isinstance(_val, bool):
|
||||
|
@ -789,7 +849,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
|
|||
R.append(
|
||||
'<div class="tf-ro-textarea">%s</div>' % html.escape(self.values[field])
|
||||
)
|
||||
elif input_type == "separator" or input_type == "hidden":
|
||||
elif (
|
||||
input_type == "separator"
|
||||
or input_type == "hidden"
|
||||
or input_type == "table_separator"
|
||||
):
|
||||
pass
|
||||
elif input_type == "file":
|
||||
R.append("'%s'" % self.values[field])
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -25,17 +25,20 @@
|
|||
#
|
||||
##############################################################################
|
||||
|
||||
"""Semestres: Codes gestion parcours (constantes)
|
||||
"""Semestres: Codes gestion cursus (constantes)
|
||||
Attention: ne pas confondre avec les "parcours" du BUT.
|
||||
Renommage des anciens "parcours" -> "cursus" effectué en 9.4.41
|
||||
"""
|
||||
import collections
|
||||
import enum
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app import log
|
||||
|
||||
|
||||
@enum.unique
|
||||
class CodesParcours(enum.IntEnum):
|
||||
"""Codes numériques des parcours, enregistrés en base
|
||||
class CodesCursus(enum.IntEnum):
|
||||
"""Codes numériques des cursus (ex parcours), enregistrés en base
|
||||
dans notes_formations.type_parcours
|
||||
Ne pas modifier.
|
||||
"""
|
||||
|
@ -77,7 +80,7 @@ UE_STANDARD = 0 # UE "fondamentale"
|
|||
UE_SPORT = 1 # bonus "sport"
|
||||
UE_STAGE_LP = 2 # ue "projet tuteuré et stage" dans les Lic. Pro.
|
||||
UE_STAGE_10 = 3 # ue "stage" avec moyenne requise > 10
|
||||
UE_ELECTIVE = 4 # UE "élective" dans certains parcours (UCAC?, ISCID)
|
||||
UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
|
||||
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
|
||||
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
|
||||
|
||||
|
@ -120,6 +123,7 @@ ABL = "ABL"
|
|||
ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé
|
||||
ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10)
|
||||
ADJ = "ADJ" # admis par le jury
|
||||
ADJR = "ADJR" # UE admise car son RCUE est ADJ
|
||||
ATT = "ATT" #
|
||||
ATJ = "ATJ" # pb assiduité: décision repoussée au semestre suivant
|
||||
ATB = "ATB"
|
||||
|
@ -156,6 +160,7 @@ CODES_EXPL = {
|
|||
ABL: "Année blanche",
|
||||
ADC: "Validé par compensation",
|
||||
ADJ: "Validé par le Jury",
|
||||
ADJR: "UE validée car son RCUE est validé ADJ par le jury",
|
||||
ADM: "Validé",
|
||||
AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)",
|
||||
ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)",
|
||||
|
@ -183,16 +188,25 @@ CODES_EXPL = {
|
|||
|
||||
# Les codes de semestres:
|
||||
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
|
||||
CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé
|
||||
CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente
|
||||
CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC}
|
||||
CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé
|
||||
CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
|
||||
|
||||
CODES_SEM_REO = {NAR: 1} # reorientation
|
||||
CODES_SEM_REO = {NAR} # reorientation
|
||||
|
||||
CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
|
||||
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
|
||||
"UE validée"
|
||||
CODES_UE_CAPITALISANTS = {ADM}
|
||||
"UE capitalisée"
|
||||
|
||||
CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
|
||||
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
|
||||
"Niveau RCUE validé"
|
||||
|
||||
CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True} # UE validée
|
||||
CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé
|
||||
# Pour le BUT:
|
||||
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
|
||||
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
|
||||
CODES_RCUE = {ADM, AJ, CMP}
|
||||
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
|
||||
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
|
||||
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
|
||||
|
@ -203,21 +217,36 @@ BUT_CODES_PASSAGE = {
|
|||
PAS1NCI,
|
||||
ATJ,
|
||||
}
|
||||
# les codes, du plus "défavorable" à l'étudiant au plus favorable:
|
||||
# (valeur par défaut 0)
|
||||
BUT_CODES_ORDERED = {
|
||||
NAR: 0,
|
||||
DEF: 0,
|
||||
AJ: 10,
|
||||
ATJ: 20,
|
||||
CMP: 50,
|
||||
ADC: 50,
|
||||
PASD: 50,
|
||||
PAS1NCI: 60,
|
||||
ADJR: 90,
|
||||
ADJ: 100,
|
||||
ADM: 100,
|
||||
}
|
||||
|
||||
|
||||
def code_semestre_validant(code: str) -> bool:
|
||||
"Vrai si ce CODE entraine la validation du semestre"
|
||||
return CODES_SEM_VALIDES.get(code, False)
|
||||
return code in CODES_SEM_VALIDES
|
||||
|
||||
|
||||
def code_semestre_attente(code: str) -> bool:
|
||||
"Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
|
||||
return CODES_SEM_ATTENTES.get(code, False)
|
||||
return code in CODES_SEM_ATTENTES
|
||||
|
||||
|
||||
def code_ue_validant(code: str) -> bool:
|
||||
"Vrai si ce code d'UE est validant (ie attribue les ECTS)"
|
||||
return CODES_UE_VALIDES.get(code, False)
|
||||
return code in CODES_UE_VALIDES
|
||||
|
||||
|
||||
DEVENIR_EXPL = {
|
||||
|
@ -246,7 +275,7 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
|
|||
|
||||
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
|
||||
|
||||
# Règles gestion parcours
|
||||
# Règles gestion cursus
|
||||
class DUTRule(object):
|
||||
def __init__(self, rule_id, premise, conclusion):
|
||||
self.rule_id = rule_id
|
||||
|
@ -268,12 +297,12 @@ class DUTRule(object):
|
|||
return True
|
||||
|
||||
|
||||
# Types de parcours
|
||||
DEFAULT_TYPE_PARCOURS = 100 # pour le menu de creation nouvelle formation
|
||||
# Types de cursus
|
||||
DEFAULT_TYPE_CURSUS = 100 # pour le menu de creation nouvelle formation
|
||||
|
||||
|
||||
class TypeParcours(object):
|
||||
TYPE_PARCOURS = None # id, utilisé par notes_formation.type_parcours
|
||||
class TypeCursus:
|
||||
TYPE_CURSUS = None # id, utilisé par notes_formation.type_parcours
|
||||
NAME = None # required
|
||||
NB_SEM = 1 # Nombre de semestres
|
||||
COMPENSATION_UE = True # inutilisé
|
||||
|
@ -287,9 +316,9 @@ class TypeParcours(object):
|
|||
SESSION_NAME = "semestre"
|
||||
SESSION_NAME_A = "du "
|
||||
SESSION_ABBRV = "S" # S1, S2, ...
|
||||
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce parcours
|
||||
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce cursus
|
||||
UE_IS_MODULE = False # 1 seul module par UE (si plusieurs modules, etudiants censéments inscrits à un seul d'entre eux)
|
||||
ECTS_ONLY = False # Parcours avec progression basée uniquement sur les ECTS
|
||||
ECTS_ONLY = False # Cursus avec progression basée uniquement sur les ECTS
|
||||
ALLOWED_UE_TYPES = list(
|
||||
UE_TYPE_NAME.keys()
|
||||
) # par defaut, autorise tous les types d'UE
|
||||
|
@ -335,18 +364,18 @@ class TypeParcours(object):
|
|||
return False, """<b>%d UE sous la barre</b>""" % n
|
||||
|
||||
|
||||
# Parcours définis (instances de sous-classes de TypeParcours):
|
||||
TYPES_PARCOURS = collections.OrderedDict() # type : Parcours
|
||||
# Cursus définis (instances de sous-classes de TypeCursus):
|
||||
SCO_CURSUS: dict[int, TypeCursus] = {} # type : Cursus
|
||||
|
||||
|
||||
def register_parcours(Parcours):
|
||||
TYPES_PARCOURS[int(Parcours.TYPE_PARCOURS)] = Parcours
|
||||
def register_cursus(cursus: TypeCursus):
|
||||
SCO_CURSUS[int(cursus.TYPE_CURSUS)] = cursus
|
||||
|
||||
|
||||
class ParcoursBUT(TypeParcours):
|
||||
class CursusBUT(TypeCursus):
|
||||
"""BUT Bachelor Universitaire de Technologie"""
|
||||
|
||||
TYPE_PARCOURS = 700
|
||||
TYPE_CURSUS = 700
|
||||
NAME = "BUT"
|
||||
NB_SEM = 6
|
||||
COMPENSATION_UE = False
|
||||
|
@ -355,63 +384,63 @@ class ParcoursBUT(TypeParcours):
|
|||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
|
||||
|
||||
|
||||
register_parcours(ParcoursBUT())
|
||||
register_cursus(CursusBUT())
|
||||
|
||||
|
||||
class ParcoursDUT(TypeParcours):
|
||||
class CursusDUT(TypeCursus):
|
||||
"""DUT selon l'arrêté d'août 2005"""
|
||||
|
||||
TYPE_PARCOURS = 100
|
||||
TYPE_CURSUS = 100
|
||||
NAME = "DUT"
|
||||
NB_SEM = 4
|
||||
COMPENSATION_UE = True
|
||||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
|
||||
|
||||
|
||||
register_parcours(ParcoursDUT())
|
||||
register_cursus(CursusDUT())
|
||||
|
||||
|
||||
class ParcoursDUT4(ParcoursDUT):
|
||||
class CursusDUT4(CursusDUT):
|
||||
"""DUT (en 4 semestres sans compensations)"""
|
||||
|
||||
TYPE_PARCOURS = 110
|
||||
TYPE_CURSUS = 110
|
||||
NAME = "DUT4"
|
||||
COMPENSATION_UE = False
|
||||
|
||||
|
||||
register_parcours(ParcoursDUT4())
|
||||
register_cursus(CursusDUT4())
|
||||
|
||||
|
||||
class ParcoursDUTMono(TypeParcours):
|
||||
class CursusDUTMono(TypeCursus):
|
||||
"""DUT en un an (FC, Années spéciales)"""
|
||||
|
||||
TYPE_PARCOURS = 120
|
||||
TYPE_CURSUS = 120
|
||||
NAME = "DUT"
|
||||
NB_SEM = 1
|
||||
COMPENSATION_UE = False
|
||||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursDUTMono())
|
||||
register_cursus(CursusDUTMono())
|
||||
|
||||
|
||||
class ParcoursDUT2(ParcoursDUT):
|
||||
class CursusDUT2(CursusDUT):
|
||||
"""DUT en deux semestres (par ex.: années spéciales semestrialisées)"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.DUT2
|
||||
TYPE_CURSUS = CodesCursus.DUT2
|
||||
NAME = "DUT2"
|
||||
NB_SEM = 2
|
||||
|
||||
|
||||
register_parcours(ParcoursDUT2())
|
||||
register_cursus(CursusDUT2())
|
||||
|
||||
|
||||
class ParcoursLP(TypeParcours):
|
||||
class CursusLP(TypeCursus):
|
||||
"""Licence Pro (en un "semestre")
|
||||
(pour anciennes LP. Après 2014, préférer ParcoursLP2014)
|
||||
(pour anciennes LP. Après 2014, préférer CursusLP2014)
|
||||
"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LP
|
||||
TYPE_CURSUS = CodesCursus.LP
|
||||
NAME = "LP"
|
||||
NB_SEM = 1
|
||||
COMPENSATION_UE = False
|
||||
|
@ -422,35 +451,35 @@ class ParcoursLP(TypeParcours):
|
|||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursLP())
|
||||
register_cursus(CursusLP())
|
||||
|
||||
|
||||
class ParcoursLP2sem(ParcoursLP):
|
||||
class CursusLP2sem(CursusLP):
|
||||
"""Licence Pro (en deux "semestres")"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LP2sem
|
||||
TYPE_CURSUS = CodesCursus.LP2sem
|
||||
NAME = "LP2sem"
|
||||
NB_SEM = 2
|
||||
COMPENSATION_UE = True
|
||||
UNUSED_CODES = set((ADC,)) # autorise les codes ATT et ATB, mais pas ADC.
|
||||
|
||||
|
||||
register_parcours(ParcoursLP2sem())
|
||||
register_cursus(CursusLP2sem())
|
||||
|
||||
|
||||
class ParcoursLP2semEvry(ParcoursLP):
|
||||
class CursusLP2semEvry(CursusLP):
|
||||
"""Licence Pro (en deux "semestres", U. Evry)"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LP2semEvry
|
||||
TYPE_CURSUS = CodesCursus.LP2semEvry
|
||||
NAME = "LP2semEvry"
|
||||
NB_SEM = 2
|
||||
COMPENSATION_UE = True
|
||||
|
||||
|
||||
register_parcours(ParcoursLP2semEvry())
|
||||
register_cursus(CursusLP2semEvry())
|
||||
|
||||
|
||||
class ParcoursLP2014(TypeParcours):
|
||||
class CursusLP2014(TypeCursus):
|
||||
"""Licence Pro (en un "semestre"), selon arrêté du 22/01/2014"""
|
||||
|
||||
# Note: texte de référence
|
||||
|
@ -467,7 +496,7 @@ class ParcoursLP2014(TypeParcours):
|
|||
# l'établissement d'un coefficient qui peut varier dans un rapport de 1 à 3. ", etc ne sont _pas_
|
||||
# vérifiés par ScoDoc)
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LP2014
|
||||
TYPE_CURSUS = CodesCursus.LP2014
|
||||
NAME = "LP2014"
|
||||
NB_SEM = 1
|
||||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_LP]
|
||||
|
@ -487,7 +516,7 @@ class ParcoursLP2014(TypeParcours):
|
|||
(ue_status["moy"], ue_status["coef_ue"])
|
||||
for ue_status in ues_status
|
||||
if ue_status["ue"]["type"] == UE_STAGE_LP
|
||||
and type(ue_status["moy"]) == float
|
||||
and np.issubdtype(type(ue_status["moy"]), np.floating)
|
||||
]
|
||||
# Moyenne des moyennes:
|
||||
sum_coef = sum(x[1] for x in mc_stages_proj)
|
||||
|
@ -505,74 +534,74 @@ class ParcoursLP2014(TypeParcours):
|
|||
return True, "" # pas de coef, condition ok
|
||||
|
||||
|
||||
register_parcours(ParcoursLP2014())
|
||||
register_cursus(CursusLP2014())
|
||||
|
||||
|
||||
class ParcoursLP2sem2014(ParcoursLP):
|
||||
class CursusLP2sem2014(CursusLP):
|
||||
"""Licence Pro (en deux "semestres", selon arrêté du 22/01/2014)"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LP2sem2014
|
||||
TYPE_CURSUS = CodesCursus.LP2sem2014
|
||||
NAME = "LP2014_2sem"
|
||||
NB_SEM = 2
|
||||
|
||||
|
||||
register_parcours(ParcoursLP2sem2014())
|
||||
register_cursus(CursusLP2sem2014())
|
||||
|
||||
|
||||
# Masters: M2 en deux semestres
|
||||
class ParcoursM2(TypeParcours):
|
||||
class CursusM2(TypeCursus):
|
||||
"""Master 2 (en deux "semestres")"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.M2
|
||||
TYPE_CURSUS = CodesCursus.M2
|
||||
NAME = "M2sem"
|
||||
NB_SEM = 2
|
||||
COMPENSATION_UE = True
|
||||
UNUSED_CODES = set((ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursM2())
|
||||
register_cursus(CursusM2())
|
||||
|
||||
|
||||
class ParcoursM2noncomp(ParcoursM2):
|
||||
class CursusM2noncomp(CursusM2):
|
||||
"""Master 2 (en deux "semestres") sans compensation"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.M2noncomp
|
||||
TYPE_CURSUS = CodesCursus.M2noncomp
|
||||
NAME = "M2noncomp"
|
||||
COMPENSATION_UE = False
|
||||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursM2noncomp())
|
||||
register_cursus(CursusM2noncomp())
|
||||
|
||||
|
||||
class ParcoursMono(TypeParcours):
|
||||
class CursusMono(TypeCursus):
|
||||
"""Formation générique en une session"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.Mono
|
||||
TYPE_CURSUS = CodesCursus.Mono
|
||||
NAME = "Mono"
|
||||
NB_SEM = 1
|
||||
COMPENSATION_UE = False
|
||||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursMono())
|
||||
register_cursus(CursusMono())
|
||||
|
||||
|
||||
class ParcoursLegacy(TypeParcours):
|
||||
class CursusLegacy(TypeCursus):
|
||||
"""DUT (ancien ScoDoc, ne plus utiliser)"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.Legacy
|
||||
TYPE_CURSUS = CodesCursus.Legacy
|
||||
NAME = "DUT"
|
||||
NB_SEM = 4
|
||||
COMPENSATION_UE = None # backward compat: defini dans formsemestre
|
||||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
|
||||
|
||||
|
||||
register_parcours(ParcoursLegacy())
|
||||
register_cursus(CursusLegacy())
|
||||
|
||||
|
||||
class ParcoursISCID(TypeParcours):
|
||||
"""Superclasse pour les parcours de l'ISCID"""
|
||||
class CursusISCID(TypeCursus):
|
||||
"""Superclasse pour les cursus de l'ISCID"""
|
||||
|
||||
# SESSION_NAME = "année"
|
||||
# SESSION_NAME_A = "de l'"
|
||||
|
@ -591,32 +620,32 @@ class ParcoursISCID(TypeParcours):
|
|||
ECTS_PROF_DIPL = 0 # crédits professionnels requis pour obtenir le diplôme
|
||||
|
||||
|
||||
class ParcoursBachelorISCID6(ParcoursISCID):
|
||||
class CursusBachelorISCID6(CursusISCID):
|
||||
"""ISCID: Bachelor en 3 ans (6 sem.)"""
|
||||
|
||||
NAME = "ParcoursBachelorISCID6"
|
||||
TYPE_PARCOURS = CodesParcours.ISCID6
|
||||
NAME = "CursusBachelorISCID6"
|
||||
TYPE_CURSUS = CodesCursus.ISCID6
|
||||
NAME = ""
|
||||
NB_SEM = 6
|
||||
ECTS_PROF_DIPL = 8 # crédits professionnels requis pour obtenir le diplôme
|
||||
|
||||
|
||||
register_parcours(ParcoursBachelorISCID6())
|
||||
register_cursus(CursusBachelorISCID6())
|
||||
|
||||
|
||||
class ParcoursMasterISCID4(ParcoursISCID):
|
||||
class CursusMasterISCID4(CursusISCID):
|
||||
"ISCID: Master en 2 ans (4 sem.)"
|
||||
TYPE_PARCOURS = CodesParcours.ISCID4
|
||||
NAME = "ParcoursMasterISCID4"
|
||||
TYPE_CURSUS = CodesCursus.ISCID4
|
||||
NAME = "CursusMasterISCID4"
|
||||
NB_SEM = 4
|
||||
ECTS_PROF_DIPL = 15 # crédits professionnels requis pour obtenir le diplôme
|
||||
|
||||
|
||||
register_parcours(ParcoursMasterISCID4())
|
||||
register_cursus(CursusMasterISCID4())
|
||||
|
||||
|
||||
class ParcoursILEPS(TypeParcours):
|
||||
"""Superclasse pour les parcours de l'ILEPS"""
|
||||
class CursusILEPS(TypeCursus):
|
||||
"""Superclasse pour les cursus de l'ILEPS"""
|
||||
|
||||
# SESSION_NAME = "année"
|
||||
# SESSION_NAME_A = "de l'"
|
||||
|
@ -632,18 +661,18 @@ class ParcoursILEPS(TypeParcours):
|
|||
BARRE_UE_DEFAULT = 0.0 # pas de barre sur les autres UE
|
||||
|
||||
|
||||
class ParcoursLicenceILEPS6(ParcoursILEPS):
|
||||
class CursusLicenceILEPS6(CursusILEPS):
|
||||
"""ILEPS: Licence 6 semestres"""
|
||||
|
||||
TYPE_PARCOURS = 1010
|
||||
TYPE_CURSUS = 1010
|
||||
NAME = "LicenceILEPS6"
|
||||
NB_SEM = 6
|
||||
|
||||
|
||||
register_parcours(ParcoursLicenceILEPS6())
|
||||
register_cursus(CursusLicenceILEPS6())
|
||||
|
||||
|
||||
class ParcoursUCAC(TypeParcours):
|
||||
class CursusUCAC(TypeCursus):
|
||||
"""Règles de validation UCAC"""
|
||||
|
||||
SESSION_NAME = "année"
|
||||
|
@ -657,79 +686,79 @@ class ParcoursUCAC(TypeParcours):
|
|||
)
|
||||
|
||||
|
||||
class ParcoursLicenceUCAC3(ParcoursUCAC):
|
||||
class CursusLicenceUCAC3(CursusUCAC):
|
||||
"""UCAC: Licence en 3 sessions d'un an"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.LicenceUCAC3
|
||||
TYPE_CURSUS = CodesCursus.LicenceUCAC3
|
||||
NAME = "Licence UCAC en 3 sessions d'un an"
|
||||
NB_SEM = 3
|
||||
|
||||
|
||||
register_parcours(ParcoursLicenceUCAC3())
|
||||
register_cursus(CursusLicenceUCAC3())
|
||||
|
||||
|
||||
class ParcoursMasterUCAC2(ParcoursUCAC):
|
||||
class CursusMasterUCAC2(CursusUCAC):
|
||||
"""UCAC: Master en 2 sessions d'un an"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.MasterUCAC2
|
||||
TYPE_CURSUS = CodesCursus.MasterUCAC2
|
||||
NAME = "Master UCAC en 2 sessions d'un an"
|
||||
NB_SEM = 2
|
||||
|
||||
|
||||
register_parcours(ParcoursMasterUCAC2())
|
||||
register_cursus(CursusMasterUCAC2())
|
||||
|
||||
|
||||
class ParcoursMonoUCAC(ParcoursUCAC):
|
||||
class CursusMonoUCAC(CursusUCAC):
|
||||
"""UCAC: Formation en 1 session de durée variable"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.MonoUCAC
|
||||
TYPE_CURSUS = CodesCursus.MonoUCAC
|
||||
NAME = "Formation UCAC en 1 session de durée variable"
|
||||
NB_SEM = 1
|
||||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursMonoUCAC())
|
||||
register_cursus(CursusMonoUCAC())
|
||||
|
||||
|
||||
class Parcours6Sem(TypeParcours):
|
||||
"""Parcours générique en 6 semestres"""
|
||||
class Cursus6Sem(TypeCursus):
|
||||
"""Cursus générique en 6 semestres"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.GEN_6_SEM
|
||||
TYPE_CURSUS = CodesCursus.GEN_6_SEM
|
||||
NAME = "Formation en 6 semestres"
|
||||
NB_SEM = 6
|
||||
COMPENSATION_UE = True
|
||||
|
||||
|
||||
register_parcours(Parcours6Sem())
|
||||
register_cursus(Cursus6Sem())
|
||||
|
||||
# # En cours d'implémentation:
|
||||
# class ParcoursLicenceLMD(TypeParcours):
|
||||
# class CursusLicenceLMD(TypeCursus):
|
||||
# """Licence standard en 6 semestres dans le LMD"""
|
||||
# TYPE_PARCOURS = 401
|
||||
# TYPE_CURSUS = 401
|
||||
# NAME = "Licence LMD"
|
||||
# NB_SEM = 6
|
||||
# COMPENSATION_UE = True
|
||||
|
||||
# register_parcours(ParcoursLicenceLMD())
|
||||
# register_cursus(CursusLicenceLMD())
|
||||
|
||||
|
||||
class ParcoursMasterLMD(TypeParcours):
|
||||
class CursusMasterLMD(TypeCursus):
|
||||
"""Master générique en 4 semestres dans le LMD"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.MasterLMD
|
||||
TYPE_CURSUS = CodesCursus.MasterLMD
|
||||
NAME = "Master LMD"
|
||||
NB_SEM = 4
|
||||
COMPENSATION_UE = True # variabale inutilisée
|
||||
UNUSED_CODES = set((ADC, ATT, ATB))
|
||||
|
||||
|
||||
register_parcours(ParcoursMasterLMD())
|
||||
register_cursus(CursusMasterLMD())
|
||||
|
||||
|
||||
class ParcoursMasterIG(ParcoursMasterLMD):
|
||||
class CursusMasterIG(CursusMasterLMD):
|
||||
"""Master de l'Institut Galilée (U. Paris 13) en 4 semestres (LMD)"""
|
||||
|
||||
TYPE_PARCOURS = CodesParcours.MasterIG
|
||||
TYPE_CURSUS = CodesCursus.MasterIG
|
||||
NAME = "Master IG P13"
|
||||
BARRE_MOY = 10.0
|
||||
NOTES_BARRE_VALID_UE_TH = 10.0 # seuil pour valider UE
|
||||
|
@ -739,7 +768,7 @@ class ParcoursMasterIG(ParcoursMasterLMD):
|
|||
BARRE_MOY_UE_STAGE = 10.0
|
||||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_10]
|
||||
|
||||
def check_barre_ues(self, ues_status): # inspire de la fonction de ParcoursLP2014
|
||||
def check_barre_ues(self, ues_status): # inspire de la fonction de CursusLP2014
|
||||
"""True si la ou les conditions sur les UE sont valides
|
||||
moyenne d'UE > 7, ou > 10 si UE de stage
|
||||
"""
|
||||
|
@ -778,10 +807,10 @@ class ParcoursMasterIG(ParcoursMasterLMD):
|
|||
return True, "" # pas de coef, condition ok
|
||||
|
||||
|
||||
register_parcours(ParcoursMasterIG())
|
||||
register_cursus(CursusMasterIG())
|
||||
|
||||
|
||||
# Ajouter ici vos parcours, le TYPE_PARCOURS devant être unique au monde
|
||||
# Ajouter ici vos cursus, le TYPE_CURSUS devant être unique au monde
|
||||
# (avisez sur la liste de diffusion)
|
||||
|
||||
|
||||
|
@ -789,16 +818,17 @@ register_parcours(ParcoursMasterIG())
|
|||
|
||||
|
||||
# -------------------------
|
||||
_tp = list(TYPES_PARCOURS.items())
|
||||
_tp = list(SCO_CURSUS.items())
|
||||
_tp.sort(key=lambda x: x[1].__doc__) # sort by intitulé
|
||||
FORMATION_PARCOURS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
|
||||
FORMATION_PARCOURS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_PARCOURS)
|
||||
FORMATION_CURSUS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
|
||||
FORMATION_CURSUS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_CURSUS)
|
||||
|
||||
|
||||
def get_parcours_from_code(code_parcours):
|
||||
parcours = TYPES_PARCOURS.get(code_parcours)
|
||||
if parcours is None:
|
||||
log(f"Warning: invalid code_parcours: {code_parcours}")
|
||||
def get_cursus_from_code(code_cursus: int) -> TypeCursus:
|
||||
"renvoie le cursus de code indiqué"
|
||||
cursus = SCO_CURSUS.get(code_cursus)
|
||||
if cursus is None:
|
||||
log(f"Warning: invalid code_cursus: {code_cursus}")
|
||||
# default to legacy
|
||||
parcours = TYPES_PARCOURS.get(0)
|
||||
return parcours
|
||||
cursus = SCO_CURSUS.get(0)
|
||||
return cursus
|
|
@ -4,7 +4,7 @@
|
|||
#
|
||||
# Command: ./csv2rules.py misc/parcoursDUT.csv
|
||||
#
|
||||
from app.scodoc.sco_codes_parcours import (
|
||||
from app.scodoc.codes_cursus import (
|
||||
DUTRule,
|
||||
ADC,
|
||||
ADJ,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -251,7 +251,7 @@ def sco_header(
|
|||
#gtrcontent {{
|
||||
margin-left: {params["margin_left"]};
|
||||
height: 100%%;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 16px;
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
|
@ -274,21 +274,11 @@ def sco_header(
|
|||
H.append("""<div id="gtrcontent">""")
|
||||
# En attendant le replacement complet de cette fonction,
|
||||
# inclusion ici des messages flask
|
||||
H.append(render_template("flashed_messages.html"))
|
||||
H.append(render_template("flashed_messages.j2"))
|
||||
#
|
||||
# Barre menu semestre:
|
||||
H.append(formsemestre_page_title(formsemestre_id))
|
||||
|
||||
# Avertissement si mot de passe à changer
|
||||
if user_check:
|
||||
if current_user.passwd_temp:
|
||||
H.append(
|
||||
f"""<div class="passwd_warn">
|
||||
Attention !<br/>
|
||||
Vous avez reçu un mot de passe temporaire.<br/>
|
||||
Vous devez le changer: <a href="{scu.UsersURL}/form_change_password?user_name={current_user.user_name}">cliquez ici</a>
|
||||
</div>"""
|
||||
)
|
||||
#
|
||||
if head_message:
|
||||
H.append('<div class="head_message">' + html.escape(head_message) + "</div>")
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -48,26 +48,26 @@ def sidebar_common():
|
|||
url_for("users.user_info_page",
|
||||
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
|
||||
}">{current_user.user_name}</a>
|
||||
<br/><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
|
||||
<br><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
|
||||
</div>
|
||||
{sidebar_dept()}
|
||||
<h2 class="insidebar">Scolarité</h2>
|
||||
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br/>
|
||||
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br/>
|
||||
<a href="{scu.AbsencesURL()}" class="sidebar">Absences</a> <br/>
|
||||
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
|
||||
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br>
|
||||
<a href="{scu.AbsencesURL()}" class="sidebar">Absences</a> <br>
|
||||
"""
|
||||
]
|
||||
if current_user.has_permission(
|
||||
Permission.ScoUsersAdmin
|
||||
) or current_user.has_permission(Permission.ScoUsersView):
|
||||
H.append(
|
||||
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br/>"""
|
||||
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br>"""
|
||||
)
|
||||
|
||||
if current_user.has_permission(Permission.ScoChangePreferences):
|
||||
H.append(
|
||||
f"""<a href="{url_for("scolar.edit_preferences", scodoc_dept=g.scodoc_dept)}"
|
||||
class="sidebar">Paramétrage</a> <br/>"""
|
||||
class="sidebar">Paramétrage</a> <br>"""
|
||||
)
|
||||
|
||||
return "".join(H)
|
||||
|
@ -84,7 +84,7 @@ def sidebar(etudid: int = None):
|
|||
H = [
|
||||
f"""<div class="sidebar">
|
||||
{ sidebar_common() }
|
||||
<div class="box-chercheetud">Chercher étudiant:<br/>
|
||||
<div class="box-chercheetud">Chercher étudiant:<br>
|
||||
<form method="get" id="form-chercheetud"
|
||||
action="{url_for('scolar.search_etud_in_dept', scodoc_dept=g.scodoc_dept) }">
|
||||
<div><input type="text" size="12" class="in-expnom" name="expnom" spellcheck="false"></input></div>
|
||||
|
@ -101,7 +101,6 @@ def sidebar(etudid: int = None):
|
|||
etudid = request.form.get("etudid", None)
|
||||
|
||||
if etudid is not None:
|
||||
etudi = int(etudid)
|
||||
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
|
||||
params.update(etud)
|
||||
params["fiche_url"] = url_for(
|
||||
|
@ -121,7 +120,7 @@ def sidebar(etudid: int = None):
|
|||
nbabsnj = nbabs - nbabsjust
|
||||
H.append(
|
||||
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">(1/2 j.)
|
||||
<br/>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
|
||||
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
|
||||
)
|
||||
H.append("<ul>")
|
||||
if current_user.has_permission(Permission.ScoAbsChange):
|
||||
|
@ -150,7 +149,7 @@ def sidebar(etudid: int = None):
|
|||
# Logo
|
||||
H.append(
|
||||
f"""<div class="logo-insidebar">
|
||||
<div class="sidebar-bottom"><a href="{ url_for( 'scodoc.about', scodoc_dept=g.scodoc_dept ) }" class="sidebar">À propos</a><br/>
|
||||
<div class="sidebar-bottom"><a href="{ url_for( 'scodoc.about', scodoc_dept=g.scodoc_dept ) }" class="sidebar">À propos</a><br>
|
||||
<a href="{ scu.SCO_USER_MANUAL }" target="_blank" class="sidebar">Aide</a>
|
||||
</div></div>
|
||||
<div class="logo-logo">
|
||||
|
@ -167,6 +166,6 @@ def sidebar(etudid: int = None):
|
|||
def sidebar_dept():
|
||||
"""Partie supérieure de la marge de gauche"""
|
||||
return render_template(
|
||||
"sidebar_dept.html",
|
||||
"sidebar_dept.j2",
|
||||
prefs=sco_preferences.SemPreferences(),
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
|
||||
# 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
|
||||
|
@ -83,7 +83,7 @@ def histogram_notes(notes):
|
|||
return "\n".join(D)
|
||||
|
||||
|
||||
def make_menu(title, items, css_class="", alone=False):
|
||||
def make_menu(title, items, css_class="", alone=False) -> str:
|
||||
"""HTML snippet to render a simple drop down menu.
|
||||
items is a list of dicts:
|
||||
{ 'title' :
|
||||
|
|
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